From 67c583a2c714a534c09de921917fc7a6836b0e87 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 11:20:52 +0530 Subject: [PATCH 01/10] added regex query support for the mongodb(schema + schemaless) --- src/Database/Adapter/Mongo.php | 3 +- src/Database/Query.php | 14 + src/Database/Validator/Queries.php | 3 +- src/Database/Validator/Query/Filter.php | 1 + tests/e2e/Adapter/Scopes/DocumentTests.php | 435 +++++++++++++++++++++ 5 files changed, 454 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 009ad1f7c..a6564dd38 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2469,7 +2469,8 @@ protected function getQueryOperator(string $operator): string Query::TYPE_STARTS_WITH, Query::TYPE_NOT_STARTS_WITH, Query::TYPE_ENDS_WITH, - Query::TYPE_NOT_ENDS_WITH => '$regex', + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_REGEX => '$regex', Query::TYPE_OR => '$or', Query::TYPE_AND => '$and', default => throw new DatabaseException('Unknown operator:' . $operator . '. Must be one of ' . Query::TYPE_EQUAL . ', ' . Query::TYPE_NOT_EQUAL . ', ' . Query::TYPE_LESSER . ', ' . Query::TYPE_LESSER_EQUAL . ', ' . Query::TYPE_GREATER . ', ' . Query::TYPE_GREATER_EQUAL . ', ' . Query::TYPE_IS_NULL . ', ' . Query::TYPE_IS_NOT_NULL . ', ' . Query::TYPE_BETWEEN . ', ' . Query::TYPE_NOT_BETWEEN . ', ' . Query::TYPE_STARTS_WITH . ', ' . Query::TYPE_NOT_STARTS_WITH . ', ' . Query::TYPE_ENDS_WITH . ', ' . Query::TYPE_NOT_ENDS_WITH . ', ' . Query::TYPE_CONTAINS . ', ' . Query::TYPE_NOT_CONTAINS . ', ' . Query::TYPE_SEARCH . ', ' . Query::TYPE_NOT_SEARCH . ', ' . Query::TYPE_SELECT), diff --git a/src/Database/Query.php b/src/Database/Query.php index 60ec1d712..1c77439d3 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -26,6 +26,7 @@ class Query public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; + public const TYPE_REGEX = 'regex'; // Spatial methods public const TYPE_CROSSES = 'crosses'; @@ -109,6 +110,7 @@ class Query self::TYPE_CURSOR_BEFORE, self::TYPE_AND, self::TYPE_OR, + self::TYPE_REGEX ]; public const VECTOR_TYPES = [ @@ -1178,4 +1180,16 @@ public static function vectorEuclidean(string $attribute, array $vector): self { return new self(self::TYPE_VECTOR_EUCLIDEAN, $attribute, [$vector]); } + + /** + * Helper method to create Query with regex method + * + * @param string $attribute + * @param string $pattern + * @return Query + */ + public static function regex(string $attribute, string $pattern): self + { + return new self(self::TYPE_REGEX, $attribute, [$pattern]); + } } diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index 8066228e3..97b3f5824 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -121,7 +121,8 @@ public function isValid($value): bool Query::TYPE_NOT_TOUCHES, Query::TYPE_VECTOR_DOT, Query::TYPE_VECTOR_COSINE, - Query::TYPE_VECTOR_EUCLIDEAN => Base::METHOD_TYPE_FILTER, + Query::TYPE_VECTOR_EUCLIDEAN, + Query::TYPE_REGEX => Base::METHOD_TYPE_FILTER, default => '', }; diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 11053f14c..4c47872a8 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -334,6 +334,7 @@ public function isValid($value): bool case Query::TYPE_NOT_STARTS_WITH: case Query::TYPE_ENDS_WITH: case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_REGEX: if (count($value->getValues()) != 1) { $this->message = \ucfirst($method) . ' queries require exactly one value.'; return false; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 151a5ae26..4552b5d15 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3550,6 +3550,441 @@ public function testFindNotEndsWith(): void $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies } + public function testFindRegex(): void + { + Authorization::setRole(Role::any()->toString()); + + /** @var Database $database */ + $database = static::getDatabase(); + + $database->createCollection('movies', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); + + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute('movies', 'name', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('movies', 'director', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true)); + } + + // Create test documents + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + ])); + + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen II', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2019, + ])); + + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain America: The First Avenger', + 'director' => 'Joe Johnston', + 'year' => 2011, + ])); + + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain Marvel', + 'director' => 'Anna Boden & Ryan Fleck', + 'year' => 2019, + ])); + + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress', + 'director' => 'TBD', + 'year' => 2025, + ])); + + $database->createDocument('movies', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress 2', + 'director' => 'TBD', + 'year' => 2026, + ])); + + // Helper function to verify regex query completeness + $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database) { + // Convert regex pattern to PHP regex format + $phpPattern = '/' . str_replace('/', '\/', $regexPattern) . '/'; + + // Get all documents to manually verify + $allDocuments = $database->find('movies'); + + // Manually filter documents that match the pattern + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $value = $doc->getAttribute($attribute); + if (preg_match($phpPattern, $value)) { + $expectedMatches[] = $doc->getId(); + } + } + + // Get IDs from query results + $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + + // Verify no extra documents are returned + foreach ($queryResults as $doc) { + $value = $doc->getAttribute($attribute); + $this->assertTrue( + (bool) preg_match($phpPattern, $value), + "Document '{$doc->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" + ); + } + + // Verify all expected documents are returned (no missing) + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" + ); + }; + + // Test basic regex pattern - match movies starting with 'Captain' + // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) + $pattern = '/^Captain/'; + $documents = $database->find('movies', [ + Query::regex('name', '^Captain'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '^Captain', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + $this->assertTrue(in_array('Captain Marvel', $names)); + + // Test regex pattern - match movies containing 'Frozen' + $pattern = '/Frozen/'; + $documents = $database->find('movies', [ + Query::regex('name', 'Frozen'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Frozen', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); + + // Test regex pattern - match movies ending with 'Marvel' + $pattern = '/Marvel$/'; + $documents = $database->find('movies', [ + Query::regex('name', 'Marvel$'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Marvel$', $documents); + + $this->assertEquals(1, count($documents)); // Only Captain Marvel + $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); + + // Test regex pattern - match movies with 'Work' in the name + $pattern = '/.*Work.*/'; + $documents = $database->find('movies', [ + Query::regex('name', '.*Work.*'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*Work.*', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Work in Progress', $names)); + $this->assertTrue(in_array('Work in Progress 2', $names)); + + // Test regex pattern - match movies with 'Buck' in director + $pattern = '/.*Buck.*/'; + $documents = $database->find('movies', [ + Query::regex('director', '.*Buck.*'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('director', '.*Buck.*', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); + + // Test regex with case-sensitive pattern + // Note: Adapters may support case-insensitive regex, but we test case-sensitive here + $pattern = '/captain/'; // Case-sensitive for verification + $documents = $database->find('movies', [ + Query::regex('name', 'captain'), // lowercase + ]); + + // Verify all returned documents match the pattern (case-sensitive) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern 'captain' (case-sensitive)" + ); + } + + // Verify completeness: manually check all documents with case-sensitive matching + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + // Use case-sensitive matching to determine expected results + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'captain' (case-sensitive)" + ); + + // Test regex with case-insensitive pattern (if adapter supports it via flags) + // Test with uppercase to verify case sensitivity + $pattern = '/Captain/'; + $documents = $database->find('movies', [ + Query::regex('name', 'Captain'), // uppercase + ]); + + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern 'Captain'" + ); + } + + // Verify completeness + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain'" + ); + + // Test regex combined with other queries + $pattern = '/^Captain/'; + $documents = $database->find('movies', [ + Query::regex('name', '^Captain'), + Query::greaterThan('year', 2010), + ]); + + // Verify all returned documents match both conditions + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); + } + + // Verify completeness: manually check all documents that match both conditions + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + if (preg_match($pattern, $name) && $year > 2010) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching both regex '^Captain' and year > 2010" + ); + + // Test regex with limit + $pattern = '/.*/'; + $documents = $database->find('movies', [ + Query::regex('name', '.*'), // Match all + Query::limit(3), + ]); + + $this->assertEquals(3, count($documents)); + + // Verify all returned documents match the pattern (should match all) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + } + + // Note: With limit, we can't verify completeness, but we can verify all returned match + + // Test regex with non-matching pattern + $pattern = '/^NonExistentPattern$/'; + $documents = $database->find('movies', [ + Query::regex('name', '^NonExistentPattern$'), + ]); + + $this->assertEquals(0, count($documents)); + + // Verify no documents match (double-check by getting all and filtering) + $allDocuments = $database->find('movies'); + $matchingCount = 0; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $matchingCount++; + } + } + $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); + + // Verify completeness: no documents should be returned + $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); + + // Test regex with special characters (should be escaped or handled properly) + $pattern = '/.*:.*/'; + $documents = $database->find('movies', [ + Query::regex('name', '.*:.*'), // Match movies with colon + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*:.*', $documents); + + // Verify expected document is included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + + // Test regex search pattern - match movies with word boundaries + $pattern = '/\bWork\b/'; + $documents = $database->find('movies', [ + Query::regex('name', '\bWork\b'), + ]); + + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + } + + // Verify completeness: manually check all documents + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '\\bWork\\b'" + ); + + // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' + $pattern1 = '/Captain/'; + $pattern2 = '/Frozen/'; + $documents = $database->find('movies', [ + Query::or([ + Query::regex('name', 'Captain'), + Query::regex('name', 'Frozen'), + ]), + ]); + + // Verify all returned documents match at least one pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $matchesPattern1 = (bool) preg_match($pattern1, $name); + $matchesPattern2 = (bool) preg_match($pattern2, $name); + $this->assertTrue( + $matchesPattern1 || $matchesPattern2, + "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" + ); + } + + // Verify completeness: manually check all documents + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" + ); + } + public function testFindOrderRandom(): void { /** @var Database $database */ From dbb3c795faa7839b8297796b6306c8913546922c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 11:28:32 +0530 Subject: [PATCH 02/10] added support for regex in mysql/mariadb --- src/Database/Adapter/SQL.php | 10 +++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 49 +++++++++++++++------- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 4bd0bb653..bac50e476 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1794,6 +1794,8 @@ protected function getSQLOperator(string $method): string case Query::TYPE_NOT_ENDS_WITH: case Query::TYPE_NOT_CONTAINS: return $this->getLikeOperator(); + case Query::TYPE_REGEX: + return $this->getRegexOperator(); case Query::TYPE_VECTOR_DOT: case Query::TYPE_VECTOR_COSINE: case Query::TYPE_VECTOR_EUCLIDEAN: @@ -2284,6 +2286,14 @@ public function getLikeOperator(): string return 'LIKE'; } + /** + * @return string + */ + public function getRegexOperator(): string + { + return 'REGEXP'; + } + public function getInternalIndexesKeys(): array { return []; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 4552b5d15..0fb960396 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3751,39 +3751,56 @@ public function testFindRegex(): void $this->assertTrue(in_array('Frozen', $names)); $this->assertTrue(in_array('Frozen II', $names)); - // Test regex with case-sensitive pattern - // Note: Adapters may support case-insensitive regex, but we test case-sensitive here - $pattern = '/captain/'; // Case-sensitive for verification + // Test regex with case pattern - adapters may be case-sensitive or case-insensitive + // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive + $patternCaseSensitive = '/captain/'; + $patternCaseInsensitive = '/captain/i'; $documents = $database->find('movies', [ Query::regex('name', 'captain'), // lowercase ]); - // Verify all returned documents match the pattern (case-sensitive) + // Verify all returned documents match the pattern (case-insensitive check for verification) foreach ($documents as $doc) { $name = $doc->getAttribute('name'); + // Verify that returned documents contain 'captain' (case-insensitive check) $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern 'captain' (case-sensitive)" + (bool) preg_match($patternCaseInsensitive, $name), + "Document '{$name}' should match pattern 'captain' (case-insensitive check)" ); } - // Verify completeness: manually check all documents with case-sensitive matching + // Verify completeness: Check what the database actually returns + // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive + // We'll determine expected matches based on case-sensitive matching (pure regex behavior) + // If the adapter is case-insensitive, it will return more documents, which is fine $allDocuments = $database->find('movies'); - $expectedMatches = []; + $expectedMatchesCaseSensitive = []; + $expectedMatchesCaseInsensitive = []; foreach ($allDocuments as $doc) { $name = $doc->getAttribute('name'); - // Use case-sensitive matching to determine expected results - if (preg_match($pattern, $name)) { - $expectedMatches[] = $doc->getId(); + if (preg_match($patternCaseSensitive, $name)) { + $expectedMatchesCaseSensitive[] = $doc->getId(); + } + if (preg_match($patternCaseInsensitive, $name)) { + $expectedMatchesCaseInsensitive[] = $doc->getId(); } } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern 'captain' (case-sensitive)" + + // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) + // Check which one matches the actual results + sort($expectedMatchesCaseSensitive); + sort($expectedMatchesCaseInsensitive); + + // Verify that actual results match either case-sensitive or case-insensitive expectations + $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); + $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); + + $this->assertTrue( + $matchesCaseSensitive || $matchesCaseInsensitive, + "Query results should match either case-sensitive (" . count($expectedMatchesCaseSensitive) . " docs) or case-insensitive (" . count($expectedMatchesCaseInsensitive) . " docs) expectations. Got " . count($actualMatches) . " documents." ); // Test regex with case-insensitive pattern (if adapter supports it via flags) From 2995d343cbc159bac2a7f8beb6d7341e5fe81d5f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 13:26:43 +0530 Subject: [PATCH 03/10] add regex support methods for database adapters --- src/Database/Adapter.php | 34 ++++++++++ src/Database/Adapter/MariaDB.php | 15 ++++ src/Database/Adapter/Mongo.php | 25 +++++++ src/Database/Adapter/Pool.php | 15 ++++ src/Database/Adapter/Postgres.php | 24 +++++++ src/Database/Adapter/SQLite.php | 22 ++++++ tests/e2e/Adapter/Scopes/DocumentTests.php | 79 ++++++++++++++-------- 7 files changed, 187 insertions(+), 27 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 62a8eb7fe..b30282998 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1442,4 +1442,38 @@ public function enableAlterLocks(bool $enable): self return $this; } + + /** + * Does the adapter support trigram index? + * + * @return bool + */ + abstract public function getSupportForTrigramIndex(): bool; + + /** + * Is PCRE regex supported? + * PCRE (Perl Compatible Regular Expressions) supports \b for word boundaries + * + * @return bool + */ + abstract public function getSupportForPRCERegex(): bool; + + /** + * Is POSIX regex supported? + * POSIX regex uses \y for word boundaries instead of \b + * + * @return bool + */ + abstract public function getSupportForPOSIXRegex(): bool; + + /** + * Is regex supported at all? + * Returns true if either PCRE or POSIX regex is supported + * + * @return bool + */ + public function getSupportForRegex(): bool + { + return $this->getSupportForPRCERegex() || $this->getSupportForPOSIXRegex(); + } } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 2876139f7..142f94825 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2230,4 +2230,19 @@ public function getSupportForAlterLocks(): bool { return true; } + + public function getSupportForTrigramIndex(): bool + { + return false; + } + + public function getSupportForPRCERegex(): bool + { + return false; + } + + public function getSupportForPOSIXRegex(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index a6564dd38..91f57fa41 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2741,6 +2741,26 @@ public function getSupportForGetConnectionId(): bool return false; } + /** + * Is PCRE regex supported? + * + * @return bool + */ + public function getSupportForPRCERegex(): bool + { + return true; + } + + /** + * Is POSIX regex supported? + * + * @return bool + */ + public function getSupportForPOSIXRegex(): bool + { + return false; + } + /** * Is cache fallback supported? * @@ -3222,4 +3242,9 @@ public function getSupportForAlterLocks(): bool { return false; } + + public function getSupportForTrigramIndex(): bool + { + return false; + } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 76c98e8b2..76cb5055d 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -365,6 +365,21 @@ public function getSupportForFulltextWildcardIndex(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForPRCERegex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForPOSIXRegex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function getSupportForTrigramIndex(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForCasting(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 86da09a58..212b59c85 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -154,6 +154,7 @@ public function create(string $name): bool // Enable extensions $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS postgis')->execute(); $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS vector')->execute(); + $this->getPDO()->prepare('CREATE EXTENSION IF NOT EXISTS pg_trgm')->execute(); $collation = " CREATE COLLATION IF NOT EXISTS utf8_ci_ai ( @@ -2112,6 +2113,21 @@ public function getSupportForVectors(): bool return true; } + public function getSupportForPRCERegex(): bool + { + return false; + } + + public function getSupportForPOSIXRegex(): bool + { + return true; + } + + public function getSupportForTrigramIndex(): bool + { + return true; + } + /** * @return string */ @@ -2120,6 +2136,14 @@ public function getLikeOperator(): string return 'ILIKE'; } + /** + * @return string + */ + public function getRegexOperator(): string + { + return '~'; + } + protected function processException(PDOException $e): \Exception { // Timeout diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a3d31db68..dc7cfc83a 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1876,4 +1876,26 @@ public function getSupportForAlterLocks(): bool { return false; } + + /** + * Is PCRE regex supported? + * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function + * + * @return bool + */ + public function getSupportForPRCERegex(): bool + { + return false; + } + + /** + * Is POSIX regex supported? + * SQLite does not have native REGEXP support - it requires compile-time option or user-defined function + * + * @return bool + */ + public function getSupportForPOSIXRegex(): bool + { + return false; + } } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 0fb960396..fd45d9002 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3557,6 +3557,27 @@ public function testFindRegex(): void /** @var Database $database */ $database = static::getDatabase(); + // Skip test if regex is not supported + if (!$database->getAdapter()->getSupportForRegex()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Determine regex support type + $supportsPCRE = $database->getAdapter()->getSupportForPRCERegex(); + $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); + + // Determine word boundary pattern based on support + $wordBoundaryPattern = null; + $wordBoundaryPatternPHP = null; + if ($supportsPCRE) { + $wordBoundaryPattern = '\\b'; // PCRE uses \b + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b + } elseif ($supportsPOSIX) { + $wordBoundaryPattern = '\\y'; // POSIX uses \y + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification + } + $database->createCollection('movies', permissions: [ Permission::create(Role::any()), Permission::read(Role::any()), @@ -3930,37 +3951,41 @@ public function testFindRegex(): void $this->assertTrue(in_array('Captain America: The First Avenger', $names)); // Test regex search pattern - match movies with word boundaries - $pattern = '/\bWork\b/'; - $documents = $database->find('movies', [ - Query::regex('name', '\bWork\b'), - ]); + // Only test if word boundaries are supported (PCRE or POSIX) + if ($wordBoundaryPattern !== null) { + $dbPattern = $wordBoundaryPattern . 'Work' . $wordBoundaryPattern; + $phpPattern = '/' . $wordBoundaryPatternPHP . 'Work' . $wordBoundaryPatternPHP . '/'; + $documents = $database->find('movies', [ + Query::regex('name', $dbPattern), + ]); - // Verify all returned documents match the pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern '{$pattern}'" - ); - } + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($phpPattern, $name), + "Document '{$name}' should match pattern '{$dbPattern}'" + ); + } - // Verify completeness: manually check all documents - $allDocuments = $database->find('movies'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $expectedMatches[] = $doc->getId(); + // Verify completeness: manually check all documents + $allDocuments = $database->find('movies'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($phpPattern, $name)) { + $expectedMatches[] = $doc->getId(); + } } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$dbPattern}'" + ); } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern '\\bWork\\b'" - ); // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' $pattern1 = '/Captain/'; From 4c6dbbbd4699d46bbacd20b4eab0692b9a708123 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 13:53:33 +0530 Subject: [PATCH 04/10] add support for trigram indexes in Postgres adapter and validation --- src/Database/Adapter/Postgres.php | 10 +- src/Database/Database.php | 9 +- src/Database/Validator/Index.php | 45 ++++++- tests/e2e/Adapter/Scopes/DocumentTests.php | 5 + tests/e2e/Adapter/Scopes/IndexTests.php | 130 ++++++++++++++++++++- tests/unit/Validator/IndexTest.php | 119 +++++++++++++++++++ 6 files changed, 312 insertions(+), 6 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 212b59c85..bfce1cded 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -900,9 +900,10 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_SPATIAL, Database::INDEX_HNSW_EUCLIDEAN, Database::INDEX_HNSW_COSINE, - Database::INDEX_HNSW_DOT => 'INDEX', + Database::INDEX_HNSW_DOT, + Database::INDEX_OBJECT, + Database::INDEX_TRIGRAM => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', - Database::INDEX_OBJECT => 'INDEX', default => throw new DatabaseException('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), }; @@ -923,6 +924,11 @@ public function createIndex(string $collection, string $id, string $type, array Database::INDEX_HNSW_COSINE => " USING HNSW ({$attributes} vector_cosine_ops)", Database::INDEX_HNSW_DOT => " USING HNSW ({$attributes} vector_ip_ops)", Database::INDEX_OBJECT => " USING GIN ({$attributes})", + Database::INDEX_TRIGRAM => + " USING GIN (" . implode(', ', array_map( + fn ($a) => "$a gin_trgm_ops", + array_map('trim', explode(',', $attributes)) + )) . ")", default => " ({$attributes})", }; diff --git a/src/Database/Database.php b/src/Database/Database.php index d5595df38..0d2d971ec 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -85,6 +85,7 @@ class Database public const INDEX_HNSW_EUCLIDEAN = 'hnsw_euclidean'; public const INDEX_HNSW_COSINE = 'hnsw_cosine'; public const INDEX_HNSW_DOT = 'hnsw_dot'; + public const INDEX_TRIGRAM = 'trigram'; // Max limits public const MAX_INT = 2147483647; @@ -3665,8 +3666,14 @@ public function createIndex(string $collection, string $id, string $type, array } break; + case self::INDEX_TRIGRAM: + if (!$this->adapter->getSupportForTrigramIndex()) { + throw new DatabaseException('Trigram indexes are not supported'); + } + break; + default: - throw new DatabaseException('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); + throw new DatabaseException('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); } /** @var array $collectionAttributes */ diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index 33648feeb..8d6ca22c5 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -29,6 +29,7 @@ class Index extends Validator * @param bool $supportForMultipleFulltextIndexes * @param bool $supportForIdenticalIndexes * @param bool $supportForObjectIndexes + * @param bool $supportForTrigramIndexes * @throws DatabaseException */ public function __construct( @@ -43,7 +44,8 @@ public function __construct( protected bool $supportForAttributes = true, protected bool $supportForMultipleFulltextIndexes = true, protected bool $supportForIdenticalIndexes = true, - protected bool $supportForObjectIndexes = false + protected bool $supportForObjectIndexes = false, + protected bool $supportForTrigramIndexes = false ) { foreach ($attributes as $attribute) { $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); @@ -137,6 +139,9 @@ public function isValid($value): bool if (!$this->checkObjectIndexes($value)) { return false; } + if (!$this->checkTrigramIndexes($value)) { + return false; + } return true; } @@ -462,6 +467,44 @@ public function checkVectorIndexes(Document $index): bool return true; } + /** + * @param Document $index + * @return bool + * @throws DatabaseException + */ + public function checkTrigramIndexes(Document $index): bool + { + $type = $index->getAttribute('type'); + + if ($type !== Database::INDEX_TRIGRAM) { + return true; + } + + if ($this->supportForTrigramIndexes === false) { + $this->message = 'Trigram indexes are not supported'; + return false; + } + + $attributes = $index->getAttribute('attributes', []); + + foreach ($attributes as $attributeName) { + $attribute = $this->attributes[\strtolower($attributeName)] ?? new Document(); + if ($attribute->getAttribute('type', '') !== Database::VAR_STRING) { + $this->message = 'Trigram index can only be created on string type attributes'; + return false; + } + } + + $orders = $index->getAttribute('orders', []); + $lengths = $index->getAttribute('lengths', []); + if (!empty($orders) || \count(\array_filter($lengths)) > 0) { + $this->message = 'Trigram indexes do not support orders or lengths'; + return false; + } + + return true; + } + /** * @param Document $index * @return bool diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index fd45d9002..a49e5d8d7 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3591,6 +3591,11 @@ public function testFindRegex(): void $this->assertEquals(true, $database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true)); } + if ($database->getAdapter()->getSupportForTrigramIndex()) { + $database->createIndex('movies', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); + $database->createIndex('movies', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); + } + // Create test documents $database->createDocument('movies', new Document([ '$permissions' => [ diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index 77f276cd6..c015500d2 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -173,7 +173,9 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForVectors(), $database->getAdapter()->getSupportForAttributes(), $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes() + $database->getAdapter()->getSupportForIdenticalIndexes(), + false, + $database->getAdapter()->getSupportForTrigramIndex() ); if ($database->getAdapter()->getSupportForIdenticalIndexes()) { $errorMessage = 'Index length 701 is larger than the size for title1: 700"'; @@ -264,7 +266,9 @@ public function testIndexValidation(): void $database->getAdapter()->getSupportForVectors(), $database->getAdapter()->getSupportForAttributes(), $database->getAdapter()->getSupportForMultipleFulltextIndexes(), - $database->getAdapter()->getSupportForIdenticalIndexes() + $database->getAdapter()->getSupportForIdenticalIndexes(), + false, + $database->getAdapter()->getSupportForTrigramIndex() ); $this->assertFalse($validator->isValid($newIndex)); @@ -644,4 +648,126 @@ public function testIdenticalIndexValidation(): void $database->deleteCollection($collectionId); } } + + public function testTrigramIndex(): void + { + $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); + if (!$trigramSupport) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = 'trigram_test'; + try { + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); + $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 512, false); + + // Create trigram index on name attribute + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_name', Database::INDEX_TRIGRAM, ['name'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(1, $indexes); + $this->assertEquals('trigram_name', $indexes[0]['$id']); + $this->assertEquals(Database::INDEX_TRIGRAM, $indexes[0]['type']); + $this->assertEquals(['name'], $indexes[0]['attributes']); + + // Create another trigram index on description + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_description', Database::INDEX_TRIGRAM, ['description'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(2, $indexes); + + // Test that trigram index can be deleted + $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_name')); + $this->assertEquals(true, $database->deleteIndex($collectionId, 'trigram_description')); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $this->assertCount(0, $indexes); + + } finally { + // Clean up + $database->deleteCollection($collectionId); + } + } + + public function testTrigramIndexValidation(): void + { + $trigramSupport = $this->getDatabase()->getAdapter()->getSupportForTrigramIndex(); + if (!$trigramSupport) { + $this->expectNotToPerformAssertions(); + return; + } + + /** @var Database $database */ + $database = static::getDatabase(); + + $collectionId = 'trigram_validation_test'; + try { + $database->createCollection($collectionId); + + $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); + $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 512, false); + $database->createAttribute($collectionId, 'age', Database::VAR_INTEGER, 8, false); + + // Test: Trigram index on non-string attribute should fail + try { + $database->createIndex($collectionId, 'trigram_invalid', Database::INDEX_TRIGRAM, ['age']); + $this->fail('Expected exception when creating trigram index on non-string attribute'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); + } + + // Test: Trigram index with multiple string attributes should succeed + $this->assertEquals(true, $database->createIndex($collectionId, 'trigram_multi', Database::INDEX_TRIGRAM, ['name', 'description'])); + + $collection = $database->getCollection($collectionId); + $indexes = $collection->getAttribute('indexes'); + $trigramMultiIndex = null; + foreach ($indexes as $idx) { + if ($idx['$id'] === 'trigram_multi') { + $trigramMultiIndex = $idx; + break; + } + } + $this->assertNotNull($trigramMultiIndex); + $this->assertEquals(Database::INDEX_TRIGRAM, $trigramMultiIndex['type']); + $this->assertEquals(['name', 'description'], $trigramMultiIndex['attributes']); + + // Test: Trigram index with mixed string and non-string attributes should fail + try { + $database->createIndex($collectionId, 'trigram_mixed', Database::INDEX_TRIGRAM, ['name', 'age']); + $this->fail('Expected exception when creating trigram index with mixed attribute types'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $e->getMessage()); + } + + // Test: Trigram index with orders should fail + try { + $database->createIndex($collectionId, 'trigram_order', Database::INDEX_TRIGRAM, ['name'], [], [Database::ORDER_ASC]); + $this->fail('Expected exception when creating trigram index with orders'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); + } + + // Test: Trigram index with lengths should fail + try { + $database->createIndex($collectionId, 'trigram_length', Database::INDEX_TRIGRAM, ['name'], [128]); + $this->fail('Expected exception when creating trigram index with lengths'); + } catch (Exception $e) { + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $e->getMessage()); + } + + } finally { + // Clean up + $database->deleteCollection($collectionId); + } + } } diff --git a/tests/unit/Validator/IndexTest.php b/tests/unit/Validator/IndexTest.php index 608a65d2b..5dfe80e4e 100644 --- a/tests/unit/Validator/IndexTest.php +++ b/tests/unit/Validator/IndexTest.php @@ -477,4 +477,123 @@ public function testIndexWithNoAttributeSupport(): void $index = $collection->getAttribute('indexes')[0]; $this->assertTrue($validator->isValid($index)); } + + /** + * @throws Exception + */ + public function testTrigramIndexValidation(): void + { + $collection = new Document([ + '$id' => ID::custom('test'), + 'name' => 'test', + 'attributes' => [ + new Document([ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 255, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('description'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 512, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + new Document([ + '$id' => ID::custom('age'), + 'type' => Database::VAR_INTEGER, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ]), + ], + 'indexes' => [] + ]); + + // Validator with supportForTrigramIndexes enabled + $validator = new Index($collection->getAttribute('attributes'), $collection->getAttribute('indexes', []), 768, [], false, false, false, false, false, false, false, false, supportForTrigramIndexes: true); + + // Valid: Trigram index on single VAR_STRING attribute + $validIndex = new Document([ + '$id' => ID::custom('idx_trigram_valid'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndex)); + + // Valid: Trigram index on multiple string attributes + $validIndexMulti = new Document([ + '$id' => ID::custom('idx_trigram_multi_valid'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name', 'description'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertTrue($validator->isValid($validIndexMulti)); + + // Invalid: Trigram index on non-string attribute + $invalidIndexType = new Document([ + '$id' => ID::custom('idx_trigram_invalid_type'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['age'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexType)); + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); + + // Invalid: Trigram index with mixed string and non-string attributes + $invalidIndexMixed = new Document([ + '$id' => ID::custom('idx_trigram_mixed'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name', 'age'], + 'lengths' => [], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexMixed)); + $this->assertStringContainsString('Trigram index can only be created on string type attributes', $validator->getDescription()); + + // Invalid: Trigram index with orders + $invalidIndexOrder = new Document([ + '$id' => ID::custom('idx_trigram_order'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [], + 'orders' => ['asc'], + ]); + $this->assertFalse($validator->isValid($invalidIndexOrder)); + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); + + // Invalid: Trigram index with lengths + $invalidIndexLength = new Document([ + '$id' => ID::custom('idx_trigram_length'), + 'type' => Database::INDEX_TRIGRAM, + 'attributes' => ['name'], + 'lengths' => [128], + 'orders' => [], + ]); + $this->assertFalse($validator->isValid($invalidIndexLength)); + $this->assertStringContainsString('Trigram indexes do not support orders or lengths', $validator->getDescription()); + + // Validator with supportForTrigramIndexes disabled should reject trigram + $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('Trigram indexes are not supported', $validatorNoSupport->getDescription()); + } } From 2e13e92297eebcf44cc84b827f9dd4e8ad87ced3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:07:06 +0530 Subject: [PATCH 05/10] added cleanup of collection for the testFindRegex --- tests/e2e/Adapter/Scopes/DocumentTests.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index a49e5d8d7..bf70c52ac 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -4030,6 +4030,7 @@ public function testFindRegex(): void $actualMatches, "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" ); + $database->deleteCollection('movies'); } public function testFindOrderRandom(): void From cc90ed18445183d583b3d87a79cf3cac3d1c651b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:08:52 +0530 Subject: [PATCH 06/10] changed the collection name from movies to moviesRegex to remove clashing with existing collections --- tests/e2e/Adapter/Scopes/DocumentTests.php | 2542 ++++++++++---------- 1 file changed, 1271 insertions(+), 1271 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index bf70c52ac..46a2a7fb4 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3550,882 +3550,399 @@ public function testFindNotEndsWith(): void $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies } - public function testFindRegex(): void + public function testFindOrderRandom(): void { - Authorization::setRole(Role::any()->toString()); - /** @var Database $database */ $database = static::getDatabase(); - // Skip test if regex is not supported - if (!$database->getAdapter()->getSupportForRegex()) { + if (!$database->getAdapter()->getSupportForOrderRandom()) { $this->expectNotToPerformAssertions(); return; } - // Determine regex support type - $supportsPCRE = $database->getAdapter()->getSupportForPRCERegex(); - $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); - - // Determine word boundary pattern based on support - $wordBoundaryPattern = null; - $wordBoundaryPatternPHP = null; - if ($supportsPCRE) { - $wordBoundaryPattern = '\\b'; // PCRE uses \b - $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b - } elseif ($supportsPOSIX) { - $wordBoundaryPattern = '\\y'; // POSIX uses \y - $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification - } - - $database->createCollection('movies', permissions: [ - Permission::create(Role::any()), - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), + // Test orderRandom with default limit + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(1), ]); + $this->assertEquals(1, count($documents)); + $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document - if ($database->getAdapter()->getSupportForAttributes()) { - $this->assertEquals(true, $database->createAttribute('movies', 'name', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'director', Database::VAR_STRING, 128, true)); - $this->assertEquals(true, $database->createAttribute('movies', 'year', Database::VAR_INTEGER, 0, true)); - } - - if ($database->getAdapter()->getSupportForTrigramIndex()) { - $database->createIndex('movies', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); - $database->createIndex('movies', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); - } - - // Create test documents - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Frozen', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2013, - ])); - - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Frozen II', - 'director' => 'Chris Buck & Jennifer Lee', - 'year' => 2019, - ])); - - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Captain America: The First Avenger', - 'director' => 'Joe Johnston', - 'year' => 2011, - ])); - - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Captain Marvel', - 'director' => 'Anna Boden & Ryan Fleck', - 'year' => 2019, - ])); - - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Work in Progress', - 'director' => 'TBD', - 'year' => 2025, - ])); - - $database->createDocument('movies', new Document([ - '$permissions' => [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'name' => 'Work in Progress 2', - 'director' => 'TBD', - 'year' => 2026, - ])); - - // Helper function to verify regex query completeness - $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database) { - // Convert regex pattern to PHP regex format - $phpPattern = '/' . str_replace('/', '\/', $regexPattern) . '/'; + // Test orderRandom with multiple documents + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); + $this->assertEquals(3, count($documents)); - // Get all documents to manually verify - $allDocuments = $database->find('movies'); + // Test that orderRandom returns different results (not guaranteed but highly likely) + $firstSet = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); + $secondSet = $database->find('movies', [ + Query::orderRandom(), + Query::limit(3), + ]); - // Manually filter documents that match the pattern - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $value = $doc->getAttribute($attribute); - if (preg_match($phpPattern, $value)) { - $expectedMatches[] = $doc->getId(); - } - } + // Extract IDs for comparison + $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); + $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); - // Get IDs from query results - $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + // While not guaranteed to be different, with 6 movies and selecting 3, + // the probability of getting the same set in the same order is very low + // We'll just check that we got valid results + $this->assertEquals(3, count($firstIds)); + $this->assertEquals(3, count($secondIds)); - // Verify no extra documents are returned - foreach ($queryResults as $doc) { - $value = $doc->getAttribute($attribute); - $this->assertTrue( - (bool) preg_match($phpPattern, $value), - "Document '{$doc->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" - ); - } + // Test orderRandom with more than available documents + $documents = $database->find('movies', [ + Query::orderRandom(), + Query::limit(10), // We only have 6 movies + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents - // Verify all expected documents are returned (no missing) - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" - ); - }; + // Test orderRandom with filters + $documents = $database->find('movies', [ + Query::greaterThan('price', 10), + Query::orderRandom(), + Query::limit(2), + ]); + $this->assertLessThanOrEqual(2, count($documents)); + foreach ($documents as $document) { + $this->assertGreaterThan(10, $document['price']); + } - // Test basic regex pattern - match movies starting with 'Captain' - // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) - $pattern = '/^Captain/'; + // Test orderRandom without explicit limit (should use default) $documents = $database->find('movies', [ - Query::regex('name', '^Captain'), + Query::orderRandom(), ]); + $this->assertGreaterThan(0, count($documents)); + $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 + } - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '^Captain', $documents); + public function testFindNotBetween(): void + { + /** @var Database $database */ + $database = static::getDatabase(); - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Captain America: The First Avenger', $names)); - $this->assertTrue(in_array('Captain Marvel', $names)); + // Test notBetween with price range - should return documents outside the range + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + ]); + $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range - // Test regex pattern - match movies containing 'Frozen' - $pattern = '/Frozen/'; + // Test notBetween with range that includes no documents - should return all documents $documents = $database->find('movies', [ - Query::regex('name', 'Frozen'), + Query::notBetween('price', 30, 35), ]); + $this->assertEquals(6, count($documents)); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Frozen', $documents); + // Test notBetween with date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), + ]); + $this->assertEquals(0, count($documents)); // No movies outside this wide date range - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Frozen', $names)); - $this->assertTrue(in_array('Frozen II', $names)); + // Test notBetween with narrower date range + $documents = $database->find('movies', [ + Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), + ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - // Test regex pattern - match movies ending with 'Marvel' - $pattern = '/Marvel$/'; + // Test notBetween with updated date range $documents = $database->find('movies', [ - Query::regex('name', 'Marvel$'), + Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), ]); + $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', 'Marvel$', $documents); + // Test notBetween with year range (integer values) + $documents = $database->find('movies', [ + Query::notBetween('year', 2005, 2007), + ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range - $this->assertEquals(1, count($documents)); // Only Captain Marvel - $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); + // Test notBetween with reversed range (start > end) - should still work + $documents = $database->find('movies', [ + Query::notBetween('price', 25.99, 25.94), // Note: reversed order + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully - // Test regex pattern - match movies with 'Work' in the name - $pattern = '/.*Work.*/'; + // Test notBetween with same start and end values $documents = $database->find('movies', [ - Query::regex('name', '.*Work.*'), + Query::notBetween('year', 2006, 2006), ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*Work.*', $documents); - - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Work in Progress', $names)); - $this->assertTrue(in_array('Work in Progress 2', $names)); + // Test notBetween combined with other filters + $documents = $database->find('movies', [ + Query::notBetween('price', 25.94, 25.99), + Query::orderDesc('year'), + Query::limit(2) + ]); + $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range - // Test regex pattern - match movies with 'Buck' in director - $pattern = '/.*Buck.*/'; + // Test notBetween with extreme ranges $documents = $database->find('movies', [ - Query::regex('director', '.*Buck.*'), + Query::notBetween('year', -1000, 1000), // Very wide range ]); + $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('director', '.*Buck.*', $documents); + // Test notBetween with float precision + $documents = $database->find('movies', [ + Query::notBetween('price', 25.945, 25.955), // Very narrow range + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range + } - // Verify expected documents are included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Frozen', $names)); - $this->assertTrue(in_array('Frozen II', $names)); + public function testFindSelect(): void + { + /** @var Database $database */ + $database = static::getDatabase(); - // Test regex with case pattern - adapters may be case-sensitive or case-insensitive - // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive - $patternCaseSensitive = '/captain/'; - $patternCaseInsensitive = '/captain/i'; $documents = $database->find('movies', [ - Query::regex('name', 'captain'), // lowercase + Query::select(['name', 'year']) ]); - // Verify all returned documents match the pattern (case-insensitive check for verification) - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - // Verify that returned documents contain 'captain' (case-insensitive check) - $this->assertTrue( - (bool) preg_match($patternCaseInsensitive, $name), - "Document '{$name}' should match pattern 'captain' (case-insensitive check)" - ); - } - - // Verify completeness: Check what the database actually returns - // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive - // We'll determine expected matches based on case-sensitive matching (pure regex behavior) - // If the adapter is case-insensitive, it will return more documents, which is fine - $allDocuments = $database->find('movies'); - $expectedMatchesCaseSensitive = []; - $expectedMatchesCaseInsensitive = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($patternCaseSensitive, $name)) { - $expectedMatchesCaseSensitive[] = $doc->getId(); - } - if (preg_match($patternCaseInsensitive, $name)) { - $expectedMatchesCaseInsensitive[] = $doc->getId(); - } + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($actualMatches); - - // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) - // Check which one matches the actual results - sort($expectedMatchesCaseSensitive); - sort($expectedMatchesCaseInsensitive); - - // Verify that actual results match either case-sensitive or case-insensitive expectations - $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); - $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); - - $this->assertTrue( - $matchesCaseSensitive || $matchesCaseInsensitive, - "Query results should match either case-sensitive (" . count($expectedMatchesCaseSensitive) . " docs) or case-insensitive (" . count($expectedMatchesCaseInsensitive) . " docs) expectations. Got " . count($actualMatches) . " documents." - ); - - // Test regex with case-insensitive pattern (if adapter supports it via flags) - // Test with uppercase to verify case sensitivity - $pattern = '/Captain/'; $documents = $database->find('movies', [ - Query::regex('name', 'Captain'), // uppercase + Query::select(['name', 'year', '$id']) ]); - // Verify all returned documents match the pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern 'Captain'" - ); - } - - // Verify completeness - $allDocuments = $database->find('movies'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $expectedMatches[] = $doc->getId(); - } + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern 'Captain'" - ); - // Test regex combined with other queries - $pattern = '/^Captain/'; $documents = $database->find('movies', [ - Query::regex('name', '^Captain'), - Query::greaterThan('year', 2010), + Query::select(['name', 'year', '$sequence']) ]); - // Verify all returned documents match both conditions - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $year = $doc->getAttribute('year'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern '{$pattern}'" - ); - $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - // Verify completeness: manually check all documents that match both conditions - $allDocuments = $database->find('movies'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - $year = $doc->getAttribute('year'); - if (preg_match($pattern, $name) && $year > 2010) { - $expectedMatches[] = $doc->getId(); - } + $documents = $database->find('movies', [ + Query::select(['name', 'year', '$collection']) + ]); + + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching both regex '^Captain' and year > 2010" - ); - // Test regex with limit - $pattern = '/.*/'; $documents = $database->find('movies', [ - Query::regex('name', '.*'), // Match all - Query::limit(3), + Query::select(['name', 'year', '$createdAt']) ]); - $this->assertEquals(3, count($documents)); - - // Verify all returned documents match the pattern (should match all) - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($pattern, $name), - "Document '{$name}' should match pattern '{$pattern}'" - ); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - // Note: With limit, we can't verify completeness, but we can verify all returned match - - // Test regex with non-matching pattern - $pattern = '/^NonExistentPattern$/'; $documents = $database->find('movies', [ - Query::regex('name', '^NonExistentPattern$'), + Query::select(['name', 'year', '$updatedAt']) ]); - $this->assertEquals(0, count($documents)); - - // Verify no documents match (double-check by getting all and filtering) - $allDocuments = $database->find('movies'); - $matchingCount = 0; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern, $name)) { - $matchingCount++; - } + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); } - $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); - - // Verify completeness: no documents should be returned - $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); - // Test regex with special characters (should be escaped or handled properly) - $pattern = '/.*:.*/'; $documents = $database->find('movies', [ - Query::regex('name', '.*:.*'), // Match movies with colon + Query::select(['name', 'year', '$permissions']) ]); - // Verify completeness: all matching documents returned, no extra documents - $verifyRegexQuery('name', '.*:.*', $documents); + foreach ($documents as $document) { + $this->assertArrayHasKey('name', $document); + $this->assertArrayHasKey('year', $document); + $this->assertArrayNotHasKey('director', $document); + $this->assertArrayNotHasKey('price', $document); + $this->assertArrayNotHasKey('active', $document); + $this->assertArrayHasKey('$id', $document); + $this->assertArrayHasKey('$sequence', $document); + $this->assertArrayHasKey('$collection', $document); + $this->assertArrayHasKey('$createdAt', $document); + $this->assertArrayHasKey('$updatedAt', $document); + $this->assertArrayHasKey('$permissions', $document); + } + } - // Verify expected document is included - $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); - $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + /** @depends testFind */ + public function testForeach(): void + { + /** @var Database $database */ + $database = static::getDatabase(); - // Test regex search pattern - match movies with word boundaries - // Only test if word boundaries are supported (PCRE or POSIX) - if ($wordBoundaryPattern !== null) { - $dbPattern = $wordBoundaryPattern . 'Work' . $wordBoundaryPattern; - $phpPattern = '/' . $wordBoundaryPatternPHP . 'Work' . $wordBoundaryPatternPHP . '/'; - $documents = $database->find('movies', [ - Query::regex('name', $dbPattern), - ]); + /** + * Test, foreach goes through all the documents + */ + $documents = []; + $database->foreach('movies', queries: [Query::limit(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + $this->assertEquals(6, count($documents)); - // Verify all returned documents match the pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $this->assertTrue( - (bool) preg_match($phpPattern, $name), - "Document '{$name}' should match pattern '{$dbPattern}'" - ); - } + /** + * Test, foreach with initial cursor + */ - // Verify completeness: manually check all documents - $allDocuments = $database->find('movies'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($phpPattern, $name)) { - $expectedMatches[] = $doc->getId(); - } - } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern '{$dbPattern}'" - ); - } + $first = $documents[0]; + $documents = []; + $database->foreach('movies', queries: [Query::limit(2), Query::cursorAfter($first)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + $this->assertEquals(5, count($documents)); - // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' - $pattern1 = '/Captain/'; - $pattern2 = '/Frozen/'; - $documents = $database->find('movies', [ - Query::or([ - Query::regex('name', 'Captain'), - Query::regex('name', 'Frozen'), - ]), - ]); + /** + * Test, foreach with initial offset + */ - // Verify all returned documents match at least one pattern - foreach ($documents as $doc) { - $name = $doc->getAttribute('name'); - $matchesPattern1 = (bool) preg_match($pattern1, $name); - $matchesPattern2 = (bool) preg_match($pattern2, $name); - $this->assertTrue( - $matchesPattern1 || $matchesPattern2, - "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" - ); - } + $documents = []; + $database->foreach('movies', queries: [Query::limit(2), Query::offset(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + $this->assertEquals(4, count($documents)); - // Verify completeness: manually check all documents - $allDocuments = $database->find('movies'); - $expectedMatches = []; - foreach ($allDocuments as $doc) { - $name = $doc->getAttribute('name'); - if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { - $expectedMatches[] = $doc->getId(); - } + /** + * Test, cursor before throws error + */ + try { + $database->foreach('movies', queries: [Query::cursorBefore($documents[0]), Query::offset(2)], callback: function ($document) use (&$documents) { + $documents[] = $document; + }); + + } catch (Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + $this->assertEquals('Cursor ' . Database::CURSOR_BEFORE . ' not supported in this method.', $e->getMessage()); } - $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); - sort($expectedMatches); - sort($actualMatches); - $this->assertEquals( - $expectedMatches, - $actualMatches, - "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" - ); - $database->deleteCollection('movies'); + } - public function testFindOrderRandom(): void + /** + * @depends testFind + */ + public function testCount(): void { /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForOrderRandom()) { - $this->expectNotToPerformAssertions(); - return; - } - - // Test orderRandom with default limit - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(1), - ]); - $this->assertEquals(1, count($documents)); - $this->assertNotEmpty($documents[0]['name']); // Ensure we got a valid document - - // Test orderRandom with multiple documents - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); - $this->assertEquals(3, count($documents)); - - // Test that orderRandom returns different results (not guaranteed but highly likely) - $firstSet = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); - $secondSet = $database->find('movies', [ - Query::orderRandom(), - Query::limit(3), - ]); + $count = $database->count('movies'); + $this->assertEquals(6, $count); + $count = $database->count('movies', [Query::equal('year', [2019])]); - // Extract IDs for comparison - $firstIds = array_map(fn ($doc) => $doc['$id'], $firstSet); - $secondIds = array_map(fn ($doc) => $doc['$id'], $secondSet); + $this->assertEquals(2, $count); + $count = $database->count('movies', [Query::equal('with-dash', ['Works'])]); + $this->assertEquals(2, $count); + $count = $database->count('movies', [Query::equal('with-dash', ['Works2', 'Works3'])]); + $this->assertEquals(4, $count); - // While not guaranteed to be different, with 6 movies and selecting 3, - // the probability of getting the same set in the same order is very low - // We'll just check that we got valid results - $this->assertEquals(3, count($firstIds)); - $this->assertEquals(3, count($secondIds)); + Authorization::unsetRole('user:x'); + $count = $database->count('movies'); + $this->assertEquals(5, $count); - // Test orderRandom with more than available documents - $documents = $database->find('movies', [ - Query::orderRandom(), - Query::limit(10), // We only have 6 movies - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Should return all available documents + Authorization::disable(); + $count = $database->count('movies'); + $this->assertEquals(6, $count); + Authorization::reset(); - // Test orderRandom with filters - $documents = $database->find('movies', [ - Query::greaterThan('price', 10), - Query::orderRandom(), - Query::limit(2), - ]); - $this->assertLessThanOrEqual(2, count($documents)); - foreach ($documents as $document) { - $this->assertGreaterThan(10, $document['price']); - } + Authorization::disable(); + $count = $database->count('movies', [], 3); + $this->assertEquals(3, $count); + Authorization::reset(); - // Test orderRandom without explicit limit (should use default) - $documents = $database->find('movies', [ - Query::orderRandom(), + /** + * Test that OR queries are handled correctly + */ + Authorization::disable(); + $count = $database->count('movies', [ + Query::equal('director', ['TBD', 'Joe Johnston']), + Query::equal('year', [2025]), ]); - $this->assertGreaterThan(0, count($documents)); - $this->assertLessThanOrEqual(25, count($documents)); // Default limit is 25 + $this->assertEquals(1, $count); + Authorization::reset(); } - public function testFindNotBetween(): void + /** + * @depends testFind + */ + public function testSum(): void { /** @var Database $database */ $database = static::getDatabase(); - // Test notBetween with price range - should return documents outside the range - $documents = $database->find('movies', [ - Query::notBetween('price', 25.94, 25.99), - ]); - $this->assertEquals(4, count($documents)); // All movies except the 2 in the price range - - // Test notBetween with range that includes no documents - should return all documents - $documents = $database->find('movies', [ - Query::notBetween('price', 30, 35), - ]); - $this->assertEquals(6, count($documents)); + Authorization::setRole('user:x'); - // Test notBetween with date range - $documents = $database->find('movies', [ - Query::notBetween('$createdAt', '1975-12-06', '2050-12-06'), - ]); - $this->assertEquals(0, count($documents)); // No movies outside this wide date range + $sum = $database->sum('movies', 'year', [Query::equal('year', [2019]),]); + $this->assertEquals(2019 + 2019, $sum); + $sum = $database->sum('movies', 'year'); + $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025 + 2026, $sum); + $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); + $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); + $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - // Test notBetween with narrower date range - $documents = $database->find('movies', [ - Query::notBetween('$createdAt', '2000-01-01', '2001-01-01'), - ]); - $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - - // Test notBetween with updated date range - $documents = $database->find('movies', [ - Query::notBetween('$updatedAt', '2000-01-01T00:00:00.000+00:00', '2001-01-01T00:00:00.000+00:00'), - ]); - $this->assertEquals(6, count($documents)); // All movies should be outside this narrow range - - // Test notBetween with year range (integer values) - $documents = $database->find('movies', [ - Query::notBetween('year', 2005, 2007), - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Movies outside 2005-2007 range - - // Test notBetween with reversed range (start > end) - should still work - $documents = $database->find('movies', [ - Query::notBetween('price', 25.99, 25.94), // Note: reversed order - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Should handle reversed range gracefully - - // Test notBetween with same start and end values - $documents = $database->find('movies', [ - Query::notBetween('year', 2006, 2006), - ]); - $this->assertGreaterThanOrEqual(5, count($documents)); // All movies except those from exactly 2006 - - // Test notBetween combined with other filters - $documents = $database->find('movies', [ - Query::notBetween('price', 25.94, 25.99), - Query::orderDesc('year'), - Query::limit(2) - ]); - $this->assertEquals(2, count($documents)); // Limited results, ordered, excluding price range - - // Test notBetween with extreme ranges - $documents = $database->find('movies', [ - Query::notBetween('year', -1000, 1000), // Very wide range - ]); - $this->assertLessThanOrEqual(6, count($documents)); // Movies outside this range - - // Test notBetween with float precision - $documents = $database->find('movies', [ - Query::notBetween('price', 25.945, 25.955), // Very narrow range - ]); - $this->assertGreaterThanOrEqual(4, count($documents)); // Most movies should be outside this narrow range - } - - public function testFindSelect(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - $documents = $database->find('movies', [ - Query::select(['name', 'year']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$id']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$sequence']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$collection']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$createdAt']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$updatedAt']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - - $documents = $database->find('movies', [ - Query::select(['name', 'year', '$permissions']) - ]); - - foreach ($documents as $document) { - $this->assertArrayHasKey('name', $document); - $this->assertArrayHasKey('year', $document); - $this->assertArrayNotHasKey('director', $document); - $this->assertArrayNotHasKey('price', $document); - $this->assertArrayNotHasKey('active', $document); - $this->assertArrayHasKey('$id', $document); - $this->assertArrayHasKey('$sequence', $document); - $this->assertArrayHasKey('$collection', $document); - $this->assertArrayHasKey('$createdAt', $document); - $this->assertArrayHasKey('$updatedAt', $document); - $this->assertArrayHasKey('$permissions', $document); - } - } - - /** @depends testFind */ - public function testForeach(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - /** - * Test, foreach goes through all the documents - */ - $documents = []; - $database->foreach('movies', queries: [Query::limit(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(6, count($documents)); - - /** - * Test, foreach with initial cursor - */ - - $first = $documents[0]; - $documents = []; - $database->foreach('movies', queries: [Query::limit(2), Query::cursorAfter($first)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(5, count($documents)); - - /** - * Test, foreach with initial offset - */ - - $documents = []; - $database->foreach('movies', queries: [Query::limit(2), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - $this->assertEquals(4, count($documents)); - - /** - * Test, cursor before throws error - */ - try { - $database->foreach('movies', queries: [Query::cursorBefore($documents[0]), Query::offset(2)], callback: function ($document) use (&$documents) { - $documents[] = $document; - }); - - } catch (Throwable $e) { - $this->assertInstanceOf(DatabaseException::class, $e); - $this->assertEquals('Cursor ' . Database::CURSOR_BEFORE . ' not supported in this method.', $e->getMessage()); - } - - } - - /** - * @depends testFind - */ - public function testCount(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - $count = $database->count('movies'); - $this->assertEquals(6, $count); - $count = $database->count('movies', [Query::equal('year', [2019])]); - - $this->assertEquals(2, $count); - $count = $database->count('movies', [Query::equal('with-dash', ['Works'])]); - $this->assertEquals(2, $count); - $count = $database->count('movies', [Query::equal('with-dash', ['Works2', 'Works3'])]); - $this->assertEquals(4, $count); - - Authorization::unsetRole('user:x'); - $count = $database->count('movies'); - $this->assertEquals(5, $count); - - Authorization::disable(); - $count = $database->count('movies'); - $this->assertEquals(6, $count); - Authorization::reset(); - - Authorization::disable(); - $count = $database->count('movies', [], 3); - $this->assertEquals(3, $count); - Authorization::reset(); - - /** - * Test that OR queries are handled correctly - */ - Authorization::disable(); - $count = $database->count('movies', [ - Query::equal('director', ['TBD', 'Joe Johnston']), - Query::equal('year', [2025]), - ]); - $this->assertEquals(1, $count); - Authorization::reset(); - } - - /** - * @depends testFind - */ - public function testSum(): void - { - /** @var Database $database */ - $database = static::getDatabase(); - - Authorization::setRole('user:x'); - - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019]),]); - $this->assertEquals(2019 + 2019, $sum); - $sum = $database->sum('movies', 'year'); - $this->assertEquals(2013 + 2019 + 2011 + 2019 + 2025 + 2026, $sum); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - $sum = $database->sum('movies', 'price', [Query::equal('year', [2019]),]); - $this->assertEquals(round(39.50 + 25.99, 2), round($sum, 2)); - - $sum = $database->sum('movies', 'year', [Query::equal('year', [2019])], 1); - $this->assertEquals(2019, $sum); + $sum = $database->sum('movies', 'year', [Query::equal('year', [2019])], 1); + $this->assertEquals(2019, $sum); Authorization::unsetRole('user:x'); Authorization::unsetRole('userx'); @@ -6399,637 +5916,1120 @@ public function testUpsertDateOperations(): void }); $updatedUpsertDoc3 = $updatedUpsertResults3[0]; - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); - $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$createdAt')); + $this->assertNotEquals($customDate, $updatedUpsertDoc3->getAttribute('$updatedAt')); + + // Test 6: Bulk upsert operations with custom dates + $database->setPreserveDates(true); + + // Test 7: Bulk upsert with different date configurations + $upsertDocuments = [ + new Document([ + '$id' => 'bulk_upsert1', + '$permissions' => $permissions, + 'string' => 'bulk_upsert1_initial', + '$createdAt' => $createDate + ]), + new Document([ + '$id' => 'bulk_upsert2', + '$permissions' => $permissions, + 'string' => 'bulk_upsert2_initial', + '$updatedAt' => $updateDate + ]), + new Document([ + '$id' => 'bulk_upsert3', + '$permissions' => $permissions, + 'string' => 'bulk_upsert3_initial', + '$createdAt' => $createDate, + '$updatedAt' => $updateDate + ]), + new Document([ + '$id' => 'bulk_upsert4', + '$permissions' => $permissions, + 'string' => 'bulk_upsert4_initial' + ]) + ]; + + $bulkUpsertResults = []; + $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { + $bulkUpsertResults[] = $doc; + }); + + // Test 8: Verify initial bulk upsert state + foreach (['bulk_upsert1', 'bulk_upsert3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + } + + foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } + + foreach (['bulk_upsert4'] as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); + $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); + } + + // Test 9: Bulk upsert update with custom dates using updateDocuments + $newDate = '2000-04-01T12:00:00.000+00:00'; + $updateUpsertDoc = new Document([ + 'string' => 'bulk_upsert_updated', + '$createdAt' => $newDate, + '$updatedAt' => $newDate + ]); + + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } + + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds) + ]); + + foreach ($upsertIds as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + $this->assertEquals('bulk_upsert_updated', $doc->getAttribute('string'), "string mismatch for $id"); + } + + // Test 10: checking by passing null to each + $updateUpsertDoc = new Document([ + 'string' => 'bulk_upsert_updated', + '$createdAt' => null, + '$updatedAt' => null + ]); + + $upsertIds = []; + foreach ($upsertDocuments as $doc) { + $upsertIds[] = $doc->getId(); + } + + $database->updateDocuments($collection, $updateUpsertDoc, [ + Query::equal('$id', $upsertIds) + ]); + + foreach ($upsertIds as $id) { + $doc = $database->getDocument($collection, $id); + $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); + $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + } + + // Test 11: Bulk upsert operations with upsertDocuments + $upsertUpdateDocuments = []; + foreach ($upsertDocuments as $doc) { + $updatedDoc = clone $doc; + $updatedDoc->setAttribute('string', 'bulk_upsert_updated_via_upsert'); + $updatedDoc->setAttribute('$createdAt', $newDate); + $updatedDoc->setAttribute('$updatedAt', $newDate); + $upsertUpdateDocuments[] = $updatedDoc; + } + + $upsertUpdateResults = []; + $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertUpdate); + + foreach ($upsertUpdateResults as $doc) { + $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for upsert update"); + $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for upsert update"); + $this->assertEquals('bulk_upsert_updated_via_upsert', $doc->getAttribute('string'), "string mismatch for upsert update"); + } + + // Test 12: Bulk upsert with preserve dates disabled + $database->setPreserveDates(false); + + $customDate = 'should be ignored anyways so no error'; + $upsertDisabledDocuments = []; + foreach ($upsertDocuments as $doc) { + $disabledDoc = clone $doc; + $disabledDoc->setAttribute('string', 'bulk_upsert_disabled'); + $disabledDoc->setAttribute('$createdAt', $customDate); + $disabledDoc->setAttribute('$updatedAt', $customDate); + $upsertDisabledDocuments[] = $disabledDoc; + } + + $upsertDisabledResults = []; + $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { + $upsertDisabledResults[] = $doc; + }); + $this->assertEquals(4, $countUpsertDisabled); + + foreach ($upsertDisabledResults as $doc) { + $this->assertNotEquals($customDate, $doc->getAttribute('$createdAt'), "createdAt should not be custom date when disabled"); + $this->assertNotEquals($customDate, $doc->getAttribute('$updatedAt'), "updatedAt should not be custom date when disabled"); + $this->assertEquals('bulk_upsert_disabled', $doc->getAttribute('string'), "string mismatch for disabled upsert"); + } + + $database->setPreserveDates(false); + $database->deleteCollection($collection); + } + + public function testUpdateDocumentsCount(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + return; + } - // Test 6: Bulk upsert operations with custom dates - $database->setPreserveDates(true); + $collectionName = "update_count"; + $database->createCollection($collectionName); - // Test 7: Bulk upsert with different date configurations - $upsertDocuments = [ + $database->createAttribute($collectionName, 'key', Database::VAR_STRING, 60, false); + $database->createAttribute($collectionName, 'value', Database::VAR_STRING, 60, false); + + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + + $docs = [ new Document([ '$id' => 'bulk_upsert1', '$permissions' => $permissions, - 'string' => 'bulk_upsert1_initial', - '$createdAt' => $createDate + 'key' => 'bulk_upsert1_initial', ]), new Document([ '$id' => 'bulk_upsert2', '$permissions' => $permissions, - 'string' => 'bulk_upsert2_initial', - '$updatedAt' => $updateDate + 'key' => 'bulk_upsert2_initial', ]), new Document([ '$id' => 'bulk_upsert3', '$permissions' => $permissions, - 'string' => 'bulk_upsert3_initial', - '$createdAt' => $createDate, - '$updatedAt' => $updateDate + 'key' => 'bulk_upsert3_initial', ]), new Document([ '$id' => 'bulk_upsert4', '$permissions' => $permissions, - 'string' => 'bulk_upsert4_initial' + 'key' => 'bulk_upsert4_initial' ]) ]; + $upsertUpdateResults = []; + $count = $database->upsertDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { + $upsertUpdateResults[] = $doc; + }); + $this->assertCount(4, $upsertUpdateResults); + $this->assertEquals(4, $count); - $bulkUpsertResults = []; - $database->upsertDocuments($collection, $upsertDocuments, onNext: function ($doc) use (&$bulkUpsertResults) { - $bulkUpsertResults[] = $doc; + $updates = new Document(['value' => 'test']); + $newDocs = []; + $count = $database->updateDocuments($collectionName, $updates, onNext:function ($doc) use (&$newDocs) { + $newDocs[] = $doc; }); - // Test 8: Verify initial bulk upsert state - foreach (['bulk_upsert1', 'bulk_upsert3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($createDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - } + $this->assertCount(4, $newDocs); + $this->assertEquals(4, $count); - foreach (['bulk_upsert2', 'bulk_upsert3'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($updateDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + $database->deleteCollection($collectionName); + } + + public function testCreateUpdateDocumentsMismatch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // with different set of attributes + $colName = "docs_with_diff"; + $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())]; + $docs = [ + new Document([ + '$id' => 'doc1', + 'key' => 'doc1', + ]), + new Document([ + '$id' => 'doc2', + 'key' => 'doc2', + 'value' => 'test', + ]), + new Document([ + '$id' => 'doc3', + '$permissions' => $permissions, + 'key' => 'doc3' + ]), + ]; + $this->assertEquals(3, $database->createDocuments($colName, $docs)); + // we should get only one document as read permission provided to the last document only + $addedDocs = $database->find($colName); + $this->assertCount(1, $addedDocs); + $doc = $addedDocs[0]; + $this->assertEquals('doc3', $doc->getId()); + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + + $database->createDocument($colName, new Document([ + '$id' => 'doc4', + '$permissions' => $permissions, + 'key' => 'doc4' + ])); + + $this->assertEquals(2, $database->updateDocuments($colName, new Document(['key' => 'new doc']))); + $doc = $database->getDocument($colName, 'doc4'); + $this->assertEquals('doc4', $doc->getId()); + $this->assertEquals('value', $doc->getAttribute('value')); + + $addedDocs = $database->find($colName); + $this->assertCount(2, $addedDocs); + foreach ($addedDocs as $doc) { + $this->assertNotEmpty($doc->getPermissions()); + $this->assertCount(3, $doc->getPermissions()); + $this->assertEquals('value', $doc->getAttribute('value')); } + $database->deleteCollection($colName); + } - foreach (['bulk_upsert4'] as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt missing for $id"); - $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt missing for $id"); + public function testBypassStructureWithSupportForAttributes(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + // for schemaless the validation will be automatically skipped + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; } - // Test 9: Bulk upsert update with custom dates using updateDocuments - $newDate = '2000-04-01T12:00:00.000+00:00'; - $updateUpsertDoc = new Document([ - 'string' => 'bulk_upsert_updated', - '$createdAt' => $newDate, - '$updatedAt' => $newDate - ]); + $collectionId = 'successive_update_single'; - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); - } + $database->createCollection($collectionId); + $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, true); + $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, true); - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) + // bypass required + $database->disableValidation(); + + $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; + $docs = $database->createDocuments($collectionId, [ + new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) ]); - foreach ($upsertIds as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); - $this->assertEquals('bulk_upsert_updated', $doc->getAttribute('string'), "string mismatch for $id"); + $docs = $database->find($collectionId); + foreach ($docs as $doc) { + $this->assertArrayHasKey('attrA', $doc->getAttributes()); + $this->assertNull($doc->getAttribute('attrA')); + $this->assertEquals('B', $doc->getAttribute('attrB')); } + // reset + $database->enableValidation(); - // Test 10: checking by passing null to each - $updateUpsertDoc = new Document([ - 'string' => 'bulk_upsert_updated', - '$createdAt' => null, - '$updatedAt' => null - ]); - - $upsertIds = []; - foreach ($upsertDocuments as $doc) { - $upsertIds[] = $doc->getId(); + try { + $database->createDocuments($collectionId, [ + new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) + ]); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertInstanceOf(StructureException::class, $e); } - $database->updateDocuments($collection, $updateUpsertDoc, [ - Query::equal('$id', $upsertIds) - ]); + $database->deleteCollection($collectionId); + } - foreach ($upsertIds as $id) { - $doc = $database->getDocument($collection, $id); - $this->assertNotEmpty($doc->getAttribute('$createdAt'), "createdAt mismatch for $id"); - $this->assertNotEmpty($doc->getAttribute('$updatedAt'), "updatedAt mismatch for $id"); + public function testValidationGuardsWithNullRequired(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; } - // Test 11: Bulk upsert operations with upsertDocuments - $upsertUpdateDocuments = []; - foreach ($upsertDocuments as $doc) { - $updatedDoc = clone $doc; - $updatedDoc->setAttribute('string', 'bulk_upsert_updated_via_upsert'); - $updatedDoc->setAttribute('$createdAt', $newDate); - $updatedDoc->setAttribute('$updatedAt', $newDate); - $upsertUpdateDocuments[] = $updatedDoc; + // Base collection and attributes + $collection = 'validation_guard_all'; + $database->createCollection($collection, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], documentSecurity: true); + $database->createAttribute($collection, 'name', Database::VAR_STRING, 32, true); + $database->createAttribute($collection, 'age', Database::VAR_INTEGER, 0, true); + $database->createAttribute($collection, 'value', Database::VAR_INTEGER, 0, false); + + // 1) createDocument with null required should fail when validation enabled, pass when disabled + try { + $database->createDocument($collection, new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], + 'name' => null, + 'age' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); } - $upsertUpdateResults = []; - $countUpsertUpdate = $database->upsertDocuments($collection, $upsertUpdateDocuments, onNext: function ($doc) use (&$upsertUpdateResults) { - $upsertUpdateResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertUpdate); + $database->disableValidation(); + $doc = $database->createDocument($collection, new Document([ + '$id' => 'created-null', + '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], + 'name' => null, + 'age' => null, + ])); + $this->assertEquals('created-null', $doc->getId()); + $database->enableValidation(); + + // Seed a valid document for updates + $valid = $database->createDocument($collection, new Document([ + '$id' => 'valid', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'ok', + 'age' => 10, + ])); + $this->assertEquals('valid', $valid->getId()); + + // 2) updateDocument set required to null should fail when validation enabled, pass when disabled + try { + $database->updateDocument($collection, 'valid', new Document([ + 'age' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } - foreach ($upsertUpdateResults as $doc) { - $this->assertEquals($newDate, $doc->getAttribute('$createdAt'), "createdAt mismatch for upsert update"); - $this->assertEquals($newDate, $doc->getAttribute('$updatedAt'), "updatedAt mismatch for upsert update"); - $this->assertEquals('bulk_upsert_updated_via_upsert', $doc->getAttribute('string'), "string mismatch for upsert update"); + $database->disableValidation(); + $updated = $database->updateDocument($collection, 'valid', new Document([ + 'age' => null, + ])); + $this->assertNull($updated->getAttribute('age')); + $database->enableValidation(); + + // Seed a few valid docs for bulk update + for ($i = 0; $i < 2; $i++) { + $database->createDocument($collection, new Document([ + '$id' => 'b' . $i, + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'ok', + 'age' => 1, + ])); } - // Test 12: Bulk upsert with preserve dates disabled - $database->setPreserveDates(false); + // 3) updateDocuments setting required to null should fail when validation enabled, pass when disabled + if ($database->getAdapter()->getSupportForBatchOperations()) { + try { + $database->updateDocuments($collection, new Document([ + 'name' => null, + ])); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } - $customDate = 'should be ignored anyways so no error'; - $upsertDisabledDocuments = []; - foreach ($upsertDocuments as $doc) { - $disabledDoc = clone $doc; - $disabledDoc->setAttribute('string', 'bulk_upsert_disabled'); - $disabledDoc->setAttribute('$createdAt', $customDate); - $disabledDoc->setAttribute('$updatedAt', $customDate); - $upsertDisabledDocuments[] = $disabledDoc; + $database->disableValidation(); + $count = $database->updateDocuments($collection, new Document([ + 'name' => null, + ])); + $this->assertGreaterThanOrEqual(3, $count); // at least the seeded docs are updated + $database->enableValidation(); } - $upsertDisabledResults = []; - $countUpsertDisabled = $database->upsertDocuments($collection, $upsertDisabledDocuments, onNext: function ($doc) use (&$upsertDisabledResults) { - $upsertDisabledResults[] = $doc; - }); - $this->assertEquals(4, $countUpsertDisabled); + // 4) upsertDocumentsWithIncrease with null required should fail when validation enabled, pass when disabled + if ($database->getAdapter()->getSupportForUpserts()) { + try { + $database->upsertDocumentsWithIncrease( + collection: $collection, + attribute: 'value', + documents: [new Document([ + '$id' => 'u1', + 'name' => null, // required null + 'value' => 1, + ])] + ); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertInstanceOf(StructureException::class, $e); + } - foreach ($upsertDisabledResults as $doc) { - $this->assertNotEquals($customDate, $doc->getAttribute('$createdAt'), "createdAt should not be custom date when disabled"); - $this->assertNotEquals($customDate, $doc->getAttribute('$updatedAt'), "updatedAt should not be custom date when disabled"); - $this->assertEquals('bulk_upsert_disabled', $doc->getAttribute('string'), "string mismatch for disabled upsert"); + $database->disableValidation(); + $ucount = $database->upsertDocumentsWithIncrease( + collection: $collection, + attribute: 'value', + documents: [new Document([ + '$id' => 'u1', + 'name' => null, + 'value' => 1, + ])] + ); + $this->assertEquals(1, $ucount); + $database->enableValidation(); } - $database->setPreserveDates(false); + // Cleanup $database->deleteCollection($collection); } - public function testUpdateDocumentsCount(): void + public function testUpsertWithJSONFilters(): void { - /** @var Database $database */ $database = static::getDatabase(); - if (!$database->getAdapter()->getSupportForUpserts()) { + if (!$database->getAdapter()->getSupportForAttributes()) { $this->expectNotToPerformAssertions(); return; } - $collectionName = "update_count"; - $database->createCollection($collectionName); + // Create collection with JSON filter attribute + $collection = ID::unique(); + $database->createCollection($collection, permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]); - $database->createAttribute($collectionName, 'key', Database::VAR_STRING, 60, false); - $database->createAttribute($collectionName, 'value', Database::VAR_STRING, 60, false); + $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); + $database->createAttribute($collection, 'metadata', Database::VAR_STRING, 4000, true, filters: ['json']); - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()),Permission::update(Role::any())]; + $permissions = [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; - $docs = [ - new Document([ - '$id' => 'bulk_upsert1', - '$permissions' => $permissions, - 'key' => 'bulk_upsert1_initial', - ]), - new Document([ - '$id' => 'bulk_upsert2', - '$permissions' => $permissions, - 'key' => 'bulk_upsert2_initial', - ]), - new Document([ - '$id' => 'bulk_upsert3', - '$permissions' => $permissions, - 'key' => 'bulk_upsert3_initial', - ]), - new Document([ - '$id' => 'bulk_upsert4', - '$permissions' => $permissions, - 'key' => 'bulk_upsert4_initial' - ]) + // Test 1: Insertion (createDocument) with JSON filter + $docId1 = 'json-doc-1'; + $initialMetadata = [ + 'version' => '1.0.0', + 'tags' => ['php', 'database'], + 'config' => [ + 'debug' => false, + 'timeout' => 30 + ] ]; - $upsertUpdateResults = []; - $count = $database->upsertDocuments($collectionName, $docs, onNext: function ($doc) use (&$upsertUpdateResults) { - $upsertUpdateResults[] = $doc; - }); - $this->assertCount(4, $upsertUpdateResults); - $this->assertEquals(4, $count); - $updates = new Document(['value' => 'test']); - $newDocs = []; - $count = $database->updateDocuments($collectionName, $updates, onNext:function ($doc) use (&$newDocs) { - $newDocs[] = $doc; - }); + $document1 = $database->createDocument($collection, new Document([ + '$id' => $docId1, + 'name' => 'Initial Document', + 'metadata' => $initialMetadata, + '$permissions' => $permissions, + ])); - $this->assertCount(4, $newDocs); - $this->assertEquals(4, $count); + $this->assertEquals($docId1, $document1->getId()); + $this->assertEquals('Initial Document', $document1->getAttribute('name')); + $this->assertIsArray($document1->getAttribute('metadata')); + $this->assertEquals('1.0.0', $document1->getAttribute('metadata')['version']); + $this->assertEquals(['php', 'database'], $document1->getAttribute('metadata')['tags']); - $database->deleteCollection($collectionName); - } + // Test 2: Update (updateDocument) with modified JSON filter + $updatedMetadata = [ + 'version' => '2.0.0', + 'tags' => ['php', 'database', 'json'], + 'config' => [ + 'debug' => true, + 'timeout' => 60, + 'cache' => true + ], + 'updated' => true + ]; - public function testCreateUpdateDocumentsMismatch(): void - { - /** @var Database $database */ - $database = static::getDatabase(); + $document1->setAttribute('name', 'Updated Document'); + $document1->setAttribute('metadata', $updatedMetadata); - // with different set of attributes - $colName = "docs_with_diff"; - $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())]; - $docs = [ + $updatedDoc = $database->updateDocument($collection, $docId1, $document1); + + $this->assertEquals($docId1, $updatedDoc->getId()); + $this->assertEquals('Updated Document', $updatedDoc->getAttribute('name')); + $this->assertIsArray($updatedDoc->getAttribute('metadata')); + $this->assertEquals('2.0.0', $updatedDoc->getAttribute('metadata')['version']); + $this->assertEquals(['php', 'database', 'json'], $updatedDoc->getAttribute('metadata')['tags']); + $this->assertTrue($updatedDoc->getAttribute('metadata')['config']['debug']); + $this->assertTrue($updatedDoc->getAttribute('metadata')['updated']); + + // Test 3: Upsert - Create new document (upsertDocument) + $docId2 = 'json-doc-2'; + $newMetadata = [ + 'version' => '1.5.0', + 'tags' => ['javascript', 'node'], + 'config' => [ + 'debug' => false, + 'timeout' => 45 + ] + ]; + + $document2 = new Document([ + '$id' => $docId2, + 'name' => 'New Upsert Document', + 'metadata' => $newMetadata, + '$permissions' => $permissions, + ]); + + $upsertedDoc = $database->upsertDocument($collection, $document2); + + $this->assertEquals($docId2, $upsertedDoc->getId()); + $this->assertEquals('New Upsert Document', $upsertedDoc->getAttribute('name')); + $this->assertIsArray($upsertedDoc->getAttribute('metadata')); + $this->assertEquals('1.5.0', $upsertedDoc->getAttribute('metadata')['version']); + + // Test 4: Upsert - Update existing document (upsertDocument) + $document2->setAttribute('name', 'Updated Upsert Document'); + $document2->setAttribute('metadata', [ + 'version' => '2.5.0', + 'tags' => ['javascript', 'node', 'typescript'], + 'config' => [ + 'debug' => true, + 'timeout' => 90 + ], + 'migrated' => true + ]); + + $upsertedDoc2 = $database->upsertDocument($collection, $document2); + + $this->assertEquals($docId2, $upsertedDoc2->getId()); + $this->assertEquals('Updated Upsert Document', $upsertedDoc2->getAttribute('name')); + $this->assertIsArray($upsertedDoc2->getAttribute('metadata')); + $this->assertEquals('2.5.0', $upsertedDoc2->getAttribute('metadata')['version']); + $this->assertEquals(['javascript', 'node', 'typescript'], $upsertedDoc2->getAttribute('metadata')['tags']); + $this->assertTrue($upsertedDoc2->getAttribute('metadata')['migrated']); + + // Test 5: Upsert - Bulk upsertDocuments (create and update) + $docId3 = 'json-doc-3'; + $docId4 = 'json-doc-4'; + + $bulkDocuments = [ new Document([ - '$id' => 'doc1', - 'key' => 'doc1', + '$id' => $docId3, + 'name' => 'Bulk Upsert 1', + 'metadata' => [ + 'version' => '3.0.0', + 'tags' => ['python', 'flask'], + 'config' => ['debug' => false] + ], + '$permissions' => $permissions, ]), new Document([ - '$id' => 'doc2', - 'key' => 'doc2', - 'value' => 'test', + '$id' => $docId4, + 'name' => 'Bulk Upsert 2', + 'metadata' => [ + 'version' => '3.1.0', + 'tags' => ['go', 'golang'], + 'config' => ['debug' => true] + ], + '$permissions' => $permissions, ]), + // Update existing document new Document([ - '$id' => 'doc3', + '$id' => $docId1, + 'name' => 'Bulk Updated Document', + 'metadata' => [ + 'version' => '3.0.0', + 'tags' => ['php', 'database', 'bulk'], + 'config' => [ + 'debug' => false, + 'timeout' => 120 + ], + 'bulkUpdated' => true + ], '$permissions' => $permissions, - 'key' => 'doc3' ]), ]; - $this->assertEquals(3, $database->createDocuments($colName, $docs)); - // we should get only one document as read permission provided to the last document only - $addedDocs = $database->find($colName); - $this->assertCount(1, $addedDocs); - $doc = $addedDocs[0]; - $this->assertEquals('doc3', $doc->getId()); - $this->assertNotEmpty($doc->getPermissions()); - $this->assertCount(3, $doc->getPermissions()); - $database->createDocument($colName, new Document([ - '$id' => 'doc4', - '$permissions' => $permissions, - 'key' => 'doc4' - ])); + $count = $database->upsertDocuments($collection, $bulkDocuments); + $this->assertEquals(3, $count); - $this->assertEquals(2, $database->updateDocuments($colName, new Document(['key' => 'new doc']))); - $doc = $database->getDocument($colName, 'doc4'); - $this->assertEquals('doc4', $doc->getId()); - $this->assertEquals('value', $doc->getAttribute('value')); + // Verify bulk upsert results + $bulkDoc1 = $database->getDocument($collection, $docId3); + $this->assertEquals('Bulk Upsert 1', $bulkDoc1->getAttribute('name')); + $this->assertEquals('3.0.0', $bulkDoc1->getAttribute('metadata')['version']); - $addedDocs = $database->find($colName); - $this->assertCount(2, $addedDocs); - foreach ($addedDocs as $doc) { - $this->assertNotEmpty($doc->getPermissions()); - $this->assertCount(3, $doc->getPermissions()); - $this->assertEquals('value', $doc->getAttribute('value')); - } - $database->deleteCollection($colName); + $bulkDoc2 = $database->getDocument($collection, $docId4); + $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); + $this->assertEquals('3.1.0', $bulkDoc2->getAttribute('metadata')['version']); + + $bulkDoc3 = $database->getDocument($collection, $docId1); + $this->assertEquals('Bulk Updated Document', $bulkDoc3->getAttribute('name')); + $this->assertEquals('3.0.0', $bulkDoc3->getAttribute('metadata')['version']); + $this->assertTrue($bulkDoc3->getAttribute('metadata')['bulkUpdated']); + + // Cleanup + $database->deleteCollection($collection); } - public function testBypassStructureWithSupportForAttributes(): void + public function testFindRegex(): void { + Authorization::setRole(Role::any()->toString()); + /** @var Database $database */ $database = static::getDatabase(); - // for schemaless the validation will be automatically skipped - if (!$database->getAdapter()->getSupportForAttributes()) { + + // Skip test if regex is not supported + if (!$database->getAdapter()->getSupportForRegex()) { $this->expectNotToPerformAssertions(); return; } - $collectionId = 'successive_update_single'; - - $database->createCollection($collectionId); - $database->createAttribute($collectionId, 'attrA', Database::VAR_STRING, 50, true); - $database->createAttribute($collectionId, 'attrB', Database::VAR_STRING, 50, true); + // Determine regex support type + $supportsPCRE = $database->getAdapter()->getSupportForPRCERegex(); + $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); - // bypass required - $database->disableValidation(); + // Determine word boundary pattern based on support + $wordBoundaryPattern = null; + $wordBoundaryPatternPHP = null; + if ($supportsPCRE) { + $wordBoundaryPattern = '\\b'; // PCRE uses \b + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match uses \b + } elseif ($supportsPOSIX) { + $wordBoundaryPattern = '\\y'; // POSIX uses \y + $wordBoundaryPatternPHP = '\\b'; // PHP preg_match still uses \b for verification + } - $permissions = [Permission::read(Role::any()), Permission::write(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]; - $docs = $database->createDocuments($collectionId, [ - new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) + $database->createCollection('moviesRegex', permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), ]); - $docs = $database->find($collectionId); - foreach ($docs as $doc) { - $this->assertArrayHasKey('attrA', $doc->getAttributes()); - $this->assertNull($doc->getAttribute('attrA')); - $this->assertEquals('B', $doc->getAttribute('attrB')); + if ($database->getAdapter()->getSupportForAttributes()) { + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'name', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'director', Database::VAR_STRING, 128, true)); + $this->assertEquals(true, $database->createAttribute('moviesRegex', 'year', Database::VAR_INTEGER, 0, true)); } - // reset - $database->enableValidation(); - try { - $database->createDocuments($collectionId, [ - new Document(['attrA' => null,'attrB' => 'B','$permissions' => $permissions]) - ]); - $this->fail('Failed to throw exception'); - } catch (Exception $e) { - $this->assertInstanceOf(StructureException::class, $e); + if ($database->getAdapter()->getSupportForTrigramIndex()) { + $database->createIndex('moviesRegex', 'trigram_name', Database::INDEX_TRIGRAM, ['name']); + $database->createIndex('moviesRegex', 'trigram_director', Database::INDEX_TRIGRAM, ['director']); } - $database->deleteCollection($collectionId); - } + // Create test documents + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2013, + ])); + + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Frozen II', + 'director' => 'Chris Buck & Jennifer Lee', + 'year' => 2019, + ])); + + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain America: The First Avenger', + 'director' => 'Joe Johnston', + 'year' => 2011, + ])); + + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Captain Marvel', + 'director' => 'Anna Boden & Ryan Fleck', + 'year' => 2019, + ])); + + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress', + 'director' => 'TBD', + 'year' => 2025, + ])); + + $database->createDocument('moviesRegex', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'name' => 'Work in Progress 2', + 'director' => 'TBD', + 'year' => 2026, + ])); + + // Helper function to verify regex query completeness + $verifyRegexQuery = function (string $attribute, string $regexPattern, array $queryResults) use ($database) { + // Convert regex pattern to PHP regex format + $phpPattern = '/' . str_replace('/', '\/', $regexPattern) . '/'; + + // Get all documents to manually verify + $allDocuments = $database->find('moviesRegex'); + + // Manually filter documents that match the pattern + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $value = $doc->getAttribute($attribute); + if (preg_match($phpPattern, $value)) { + $expectedMatches[] = $doc->getId(); + } + } + + // Get IDs from query results + $actualMatches = array_map(fn ($doc) => $doc->getId(), $queryResults); + + // Verify no extra documents are returned + foreach ($queryResults as $doc) { + $value = $doc->getAttribute($attribute); + $this->assertTrue( + (bool) preg_match($phpPattern, $value), + "Document '{$doc->getId()}' with {$attribute}='{$value}' should match pattern '{$regexPattern}'" + ); + } + + // Verify all expected documents are returned (no missing) + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$regexPattern}' on attribute '{$attribute}'" + ); + }; + + // Test basic regex pattern - match movies starting with 'Captain' + // Note: Pattern format may vary by adapter (MongoDB uses regex strings, SQL uses REGEXP) + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '^Captain', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); + $this->assertTrue(in_array('Captain Marvel', $names)); + + // Test regex pattern - match movies containing 'Frozen' + $pattern = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Frozen'), + ]); + + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Frozen', $documents); + + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); - public function testValidationGuardsWithNullRequired(): void - { - /** @var Database $database */ - $database = static::getDatabase(); + // Test regex pattern - match movies ending with 'Marvel' + $pattern = '/Marvel$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Marvel$'), + ]); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', 'Marvel$', $documents); - // Base collection and attributes - $collection = 'validation_guard_all'; - $database->createCollection($collection, permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], documentSecurity: true); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 32, true); - $database->createAttribute($collection, 'age', Database::VAR_INTEGER, 0, true); - $database->createAttribute($collection, 'value', Database::VAR_INTEGER, 0, false); + $this->assertEquals(1, count($documents)); // Only Captain Marvel + $this->assertEquals('Captain Marvel', $documents[0]->getAttribute('name')); - // 1) createDocument with null required should fail when validation enabled, pass when disabled - try { - $database->createDocument($collection, new Document([ - '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any())], - 'name' => null, - 'age' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } + // Test regex pattern - match movies with 'Work' in the name + $pattern = '/.*Work.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*Work.*'), + ]); - $database->disableValidation(); - $doc = $database->createDocument($collection, new Document([ - '$id' => 'created-null', - '$permissions' => [Permission::read(Role::any()), Permission::create(Role::any()), Permission::update(Role::any())], - 'name' => null, - 'age' => null, - ])); - $this->assertEquals('created-null', $doc->getId()); - $database->enableValidation(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*Work.*', $documents); - // Seed a valid document for updates - $valid = $database->createDocument($collection, new Document([ - '$id' => 'valid', - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'name' => 'ok', - 'age' => 10, - ])); - $this->assertEquals('valid', $valid->getId()); + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Work in Progress', $names)); + $this->assertTrue(in_array('Work in Progress 2', $names)); - // 2) updateDocument set required to null should fail when validation enabled, pass when disabled - try { - $database->updateDocument($collection, 'valid', new Document([ - 'age' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } + // Test regex pattern - match movies with 'Buck' in director + $pattern = '/.*Buck.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('director', '.*Buck.*'), + ]); - $database->disableValidation(); - $updated = $database->updateDocument($collection, 'valid', new Document([ - 'age' => null, - ])); - $this->assertNull($updated->getAttribute('age')); - $database->enableValidation(); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('director', '.*Buck.*', $documents); - // Seed a few valid docs for bulk update - for ($i = 0; $i < 2; $i++) { - $database->createDocument($collection, new Document([ - '$id' => 'b' . $i, - '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], - 'name' => 'ok', - 'age' => 1, - ])); - } + // Verify expected documents are included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Frozen', $names)); + $this->assertTrue(in_array('Frozen II', $names)); - // 3) updateDocuments setting required to null should fail when validation enabled, pass when disabled - if ($database->getAdapter()->getSupportForBatchOperations()) { - try { - $database->updateDocuments($collection, new Document([ - 'name' => null, - ])); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); - } + // Test regex with case pattern - adapters may be case-sensitive or case-insensitive + // MySQL/MariaDB REGEXP is case-insensitive by default, MongoDB is case-sensitive + $patternCaseSensitive = '/captain/'; + $patternCaseInsensitive = '/captain/i'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'captain'), // lowercase + ]); - $database->disableValidation(); - $count = $database->updateDocuments($collection, new Document([ - 'name' => null, - ])); - $this->assertGreaterThanOrEqual(3, $count); // at least the seeded docs are updated - $database->enableValidation(); + // Verify all returned documents match the pattern (case-insensitive check for verification) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + // Verify that returned documents contain 'captain' (case-insensitive check) + $this->assertTrue( + (bool) preg_match($patternCaseInsensitive, $name), + "Document '{$name}' should match pattern 'captain' (case-insensitive check)" + ); } - // 4) upsertDocumentsWithIncrease with null required should fail when validation enabled, pass when disabled - if ($database->getAdapter()->getSupportForUpserts()) { - try { - $database->upsertDocumentsWithIncrease( - collection: $collection, - attribute: 'value', - documents: [new Document([ - '$id' => 'u1', - 'name' => null, // required null - 'value' => 1, - ])] - ); - $this->fail('Failed to throw exception'); - } catch (Throwable $e) { - $this->assertInstanceOf(StructureException::class, $e); + // Verify completeness: Check what the database actually returns + // Some adapters (MongoDB) are case-sensitive, others (MySQL/MariaDB) are case-insensitive + // We'll determine expected matches based on case-sensitive matching (pure regex behavior) + // If the adapter is case-insensitive, it will return more documents, which is fine + $allDocuments = $database->find('moviesRegex'); + $expectedMatchesCaseSensitive = []; + $expectedMatchesCaseInsensitive = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($patternCaseSensitive, $name)) { + $expectedMatchesCaseSensitive[] = $doc->getId(); + } + if (preg_match($patternCaseInsensitive, $name)) { + $expectedMatchesCaseInsensitive[] = $doc->getId(); } - - $database->disableValidation(); - $ucount = $database->upsertDocumentsWithIncrease( - collection: $collection, - attribute: 'value', - documents: [new Document([ - '$id' => 'u1', - 'name' => null, - 'value' => 1, - ])] - ); - $this->assertEquals(1, $ucount); - $database->enableValidation(); } - // Cleanup - $database->deleteCollection($collection); - } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($actualMatches); - public function testUpsertWithJSONFilters(): void - { - $database = static::getDatabase(); + // The database might be case-sensitive (MongoDB) or case-insensitive (MySQL/MariaDB) + // Check which one matches the actual results + sort($expectedMatchesCaseSensitive); + sort($expectedMatchesCaseInsensitive); - if (!$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - return; - } + // Verify that actual results match either case-sensitive or case-insensitive expectations + $matchesCaseSensitive = ($expectedMatchesCaseSensitive === $actualMatches); + $matchesCaseInsensitive = ($expectedMatchesCaseInsensitive === $actualMatches); - // Create collection with JSON filter attribute - $collection = ID::unique(); - $database->createCollection($collection, permissions: [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]); + $this->assertTrue( + $matchesCaseSensitive || $matchesCaseInsensitive, + "Query results should match either case-sensitive (" . count($expectedMatchesCaseSensitive) . " docs) or case-insensitive (" . count($expectedMatchesCaseInsensitive) . " docs) expectations. Got " . count($actualMatches) . " documents." + ); - $database->createAttribute($collection, 'name', Database::VAR_STRING, 128, true); - $database->createAttribute($collection, 'metadata', Database::VAR_STRING, 4000, true, filters: ['json']); + // Test regex with case-insensitive pattern (if adapter supports it via flags) + // Test with uppercase to verify case sensitivity + $pattern = '/Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', 'Captain'), // uppercase + ]); - $permissions = [ - Permission::read(Role::any()), - Permission::create(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ]; + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern 'Captain'" + ); + } - // Test 1: Insertion (createDocument) with JSON filter - $docId1 = 'json-doc-1'; - $initialMetadata = [ - 'version' => '1.0.0', - 'tags' => ['php', 'database'], - 'config' => [ - 'debug' => false, - 'timeout' => 30 - ] - ]; + // Verify completeness + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain'" + ); - $document1 = $database->createDocument($collection, new Document([ - '$id' => $docId1, - 'name' => 'Initial Document', - 'metadata' => $initialMetadata, - '$permissions' => $permissions, - ])); + // Test regex combined with other queries + $pattern = '/^Captain/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^Captain'), + Query::greaterThan('year', 2010), + ]); - $this->assertEquals($docId1, $document1->getId()); - $this->assertEquals('Initial Document', $document1->getAttribute('name')); - $this->assertIsArray($document1->getAttribute('metadata')); - $this->assertEquals('1.0.0', $document1->getAttribute('metadata')['version']); - $this->assertEquals(['php', 'database'], $document1->getAttribute('metadata')['tags']); + // Verify all returned documents match both conditions + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + $this->assertGreaterThan(2010, $year, "Document '{$name}' should have year > 2010"); + } - // Test 2: Update (updateDocument) with modified JSON filter - $updatedMetadata = [ - 'version' => '2.0.0', - 'tags' => ['php', 'database', 'json'], - 'config' => [ - 'debug' => true, - 'timeout' => 60, - 'cache' => true - ], - 'updated' => true - ]; + // Verify completeness: manually check all documents that match both conditions + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + $year = $doc->getAttribute('year'); + if (preg_match($pattern, $name) && $year > 2010) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching both regex '^Captain' and year > 2010" + ); - $document1->setAttribute('name', 'Updated Document'); - $document1->setAttribute('metadata', $updatedMetadata); + // Test regex with limit + $pattern = '/.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*'), // Match all + Query::limit(3), + ]); - $updatedDoc = $database->updateDocument($collection, $docId1, $document1); + $this->assertEquals(3, count($documents)); - $this->assertEquals($docId1, $updatedDoc->getId()); - $this->assertEquals('Updated Document', $updatedDoc->getAttribute('name')); - $this->assertIsArray($updatedDoc->getAttribute('metadata')); - $this->assertEquals('2.0.0', $updatedDoc->getAttribute('metadata')['version']); - $this->assertEquals(['php', 'database', 'json'], $updatedDoc->getAttribute('metadata')['tags']); - $this->assertTrue($updatedDoc->getAttribute('metadata')['config']['debug']); - $this->assertTrue($updatedDoc->getAttribute('metadata')['updated']); + // Verify all returned documents match the pattern (should match all) + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($pattern, $name), + "Document '{$name}' should match pattern '{$pattern}'" + ); + } - // Test 3: Upsert - Create new document (upsertDocument) - $docId2 = 'json-doc-2'; - $newMetadata = [ - 'version' => '1.5.0', - 'tags' => ['javascript', 'node'], - 'config' => [ - 'debug' => false, - 'timeout' => 45 - ] - ]; + // Note: With limit, we can't verify completeness, but we can verify all returned match - $document2 = new Document([ - '$id' => $docId2, - 'name' => 'New Upsert Document', - 'metadata' => $newMetadata, - '$permissions' => $permissions, + // Test regex with non-matching pattern + $pattern = '/^NonExistentPattern$/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '^NonExistentPattern$'), ]); - $upsertedDoc = $database->upsertDocument($collection, $document2); + $this->assertEquals(0, count($documents)); - $this->assertEquals($docId2, $upsertedDoc->getId()); - $this->assertEquals('New Upsert Document', $upsertedDoc->getAttribute('name')); - $this->assertIsArray($upsertedDoc->getAttribute('metadata')); - $this->assertEquals('1.5.0', $upsertedDoc->getAttribute('metadata')['version']); + // Verify no documents match (double-check by getting all and filtering) + $allDocuments = $database->find('moviesRegex'); + $matchingCount = 0; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern, $name)) { + $matchingCount++; + } + } + $this->assertEquals(0, $matchingCount, "No documents should match pattern '{$pattern}'"); - // Test 4: Upsert - Update existing document (upsertDocument) - $document2->setAttribute('name', 'Updated Upsert Document'); - $document2->setAttribute('metadata', [ - 'version' => '2.5.0', - 'tags' => ['javascript', 'node', 'typescript'], - 'config' => [ - 'debug' => true, - 'timeout' => 90 - ], - 'migrated' => true - ]); + // Verify completeness: no documents should be returned + $this->assertEquals([], array_map(fn ($doc) => $doc->getId(), $documents)); - $upsertedDoc2 = $database->upsertDocument($collection, $document2); + // Test regex with special characters (should be escaped or handled properly) + $pattern = '/.*:.*/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', '.*:.*'), // Match movies with colon + ]); - $this->assertEquals($docId2, $upsertedDoc2->getId()); - $this->assertEquals('Updated Upsert Document', $upsertedDoc2->getAttribute('name')); - $this->assertIsArray($upsertedDoc2->getAttribute('metadata')); - $this->assertEquals('2.5.0', $upsertedDoc2->getAttribute('metadata')['version']); - $this->assertEquals(['javascript', 'node', 'typescript'], $upsertedDoc2->getAttribute('metadata')['tags']); - $this->assertTrue($upsertedDoc2->getAttribute('metadata')['migrated']); + // Verify completeness: all matching documents returned, no extra documents + $verifyRegexQuery('name', '.*:.*', $documents); - // Test 5: Upsert - Bulk upsertDocuments (create and update) - $docId3 = 'json-doc-3'; - $docId4 = 'json-doc-4'; + // Verify expected document is included + $names = array_map(fn ($doc) => $doc->getAttribute('name'), $documents); + $this->assertTrue(in_array('Captain America: The First Avenger', $names)); - $bulkDocuments = [ - new Document([ - '$id' => $docId3, - 'name' => 'Bulk Upsert 1', - 'metadata' => [ - 'version' => '3.0.0', - 'tags' => ['python', 'flask'], - 'config' => ['debug' => false] - ], - '$permissions' => $permissions, - ]), - new Document([ - '$id' => $docId4, - 'name' => 'Bulk Upsert 2', - 'metadata' => [ - 'version' => '3.1.0', - 'tags' => ['go', 'golang'], - 'config' => ['debug' => true] - ], - '$permissions' => $permissions, - ]), - // Update existing document - new Document([ - '$id' => $docId1, - 'name' => 'Bulk Updated Document', - 'metadata' => [ - 'version' => '3.0.0', - 'tags' => ['php', 'database', 'bulk'], - 'config' => [ - 'debug' => false, - 'timeout' => 120 - ], - 'bulkUpdated' => true - ], - '$permissions' => $permissions, - ]), - ]; + // Test regex search pattern - match movies with word boundaries + // Only test if word boundaries are supported (PCRE or POSIX) + if ($wordBoundaryPattern !== null) { + $dbPattern = $wordBoundaryPattern . 'Work' . $wordBoundaryPattern; + $phpPattern = '/' . $wordBoundaryPatternPHP . 'Work' . $wordBoundaryPatternPHP . '/'; + $documents = $database->find('moviesRegex', [ + Query::regex('name', $dbPattern), + ]); - $count = $database->upsertDocuments($collection, $bulkDocuments); - $this->assertEquals(3, $count); + // Verify all returned documents match the pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $this->assertTrue( + (bool) preg_match($phpPattern, $name), + "Document '{$name}' should match pattern '{$dbPattern}'" + ); + } - // Verify bulk upsert results - $bulkDoc1 = $database->getDocument($collection, $docId3); - $this->assertEquals('Bulk Upsert 1', $bulkDoc1->getAttribute('name')); - $this->assertEquals('3.0.0', $bulkDoc1->getAttribute('metadata')['version']); + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($phpPattern, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern '{$dbPattern}'" + ); + } - $bulkDoc2 = $database->getDocument($collection, $docId4); - $this->assertEquals('Bulk Upsert 2', $bulkDoc2->getAttribute('name')); - $this->assertEquals('3.1.0', $bulkDoc2->getAttribute('metadata')['version']); + // Test regex search with multiple patterns - match movies containing 'Captain' or 'Frozen' + $pattern1 = '/Captain/'; + $pattern2 = '/Frozen/'; + $documents = $database->find('moviesRegex', [ + Query::or([ + Query::regex('name', 'Captain'), + Query::regex('name', 'Frozen'), + ]), + ]); - $bulkDoc3 = $database->getDocument($collection, $docId1); - $this->assertEquals('Bulk Updated Document', $bulkDoc3->getAttribute('name')); - $this->assertEquals('3.0.0', $bulkDoc3->getAttribute('metadata')['version']); - $this->assertTrue($bulkDoc3->getAttribute('metadata')['bulkUpdated']); + // Verify all returned documents match at least one pattern + foreach ($documents as $doc) { + $name = $doc->getAttribute('name'); + $matchesPattern1 = (bool) preg_match($pattern1, $name); + $matchesPattern2 = (bool) preg_match($pattern2, $name); + $this->assertTrue( + $matchesPattern1 || $matchesPattern2, + "Document '{$name}' should match either pattern 'Captain' or 'Frozen'" + ); + } - // Cleanup - $database->deleteCollection($collection); + // Verify completeness: manually check all documents + $allDocuments = $database->find('moviesRegex'); + $expectedMatches = []; + foreach ($allDocuments as $doc) { + $name = $doc->getAttribute('name'); + if (preg_match($pattern1, $name) || preg_match($pattern2, $name)) { + $expectedMatches[] = $doc->getId(); + } + } + $actualMatches = array_map(fn ($doc) => $doc->getId(), $documents); + sort($expectedMatches); + sort($actualMatches); + $this->assertEquals( + $expectedMatches, + $actualMatches, + "Query should return exactly the documents matching pattern 'Captain' OR 'Frozen'" + ); + $database->deleteCollection('moviesRegex'); } } From e4b7c4531dc74951e4315b645edf4bcd7486a5cf Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:16:40 +0530 Subject: [PATCH 07/10] added trigram index in the index vaidator in the databases --- src/Database/Database.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 0d2d971ec..cdd4491e1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1642,6 +1642,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), $this->adapter->getSupportForObject(), + $this->adapter->getSupportForTrigramIndex(), ); foreach ($indexes as $index) { if (!$validator->isValid($index)) { @@ -2786,7 +2787,8 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->adapter->getSupportForAttributes(), $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), - $this->adapter->getSupportForObject() + $this->adapter->getSupportForObject(), + $this->adapter->getSupportForTrigramIndex() ); foreach ($indexes as $index) { @@ -3729,6 +3731,7 @@ public function createIndex(string $collection, string $id, string $type, array $this->adapter->getSupportForMultipleFulltextIndexes(), $this->adapter->getSupportForIdenticalIndexes(), $this->adapter->getSupportForObject(), + $this->adapter->getSupportForTrigramIndex(), ); if (!$validator->isValid($index)) { throw new IndexException($validator->getDescription()); From e18512d282cdb0c29719250981b9be9b20f8fb9b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:23:11 +0530 Subject: [PATCH 08/10] updated the size of the attr in testTrigramIndexValidation --- tests/e2e/Adapter/Scopes/IndexTests.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/Adapter/Scopes/IndexTests.php b/tests/e2e/Adapter/Scopes/IndexTests.php index c015500d2..ef700658f 100644 --- a/tests/e2e/Adapter/Scopes/IndexTests.php +++ b/tests/e2e/Adapter/Scopes/IndexTests.php @@ -714,7 +714,7 @@ public function testTrigramIndexValidation(): void $database->createCollection($collectionId); $database->createAttribute($collectionId, 'name', Database::VAR_STRING, 256, false); - $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 512, false); + $database->createAttribute($collectionId, 'description', Database::VAR_STRING, 412, false); $database->createAttribute($collectionId, 'age', Database::VAR_INTEGER, 8, false); // Test: Trigram index on non-string attribute should fail From d50f1f25a56783d2bf0785a133888354bb4aae15 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:33:48 +0530 Subject: [PATCH 09/10] fixed typo --- src/Database/Adapter.php | 4 ++-- src/Database/Adapter/MariaDB.php | 2 +- src/Database/Adapter/Mongo.php | 2 +- src/Database/Adapter/Pool.php | 2 +- src/Database/Adapter/Postgres.php | 2 +- src/Database/Adapter/SQLite.php | 2 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index b30282998..811539aef 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1456,7 +1456,7 @@ abstract public function getSupportForTrigramIndex(): bool; * * @return bool */ - abstract public function getSupportForPRCERegex(): bool; + abstract public function getSupportForPCRERegex(): bool; /** * Is POSIX regex supported? @@ -1474,6 +1474,6 @@ abstract public function getSupportForPOSIXRegex(): bool; */ public function getSupportForRegex(): bool { - return $this->getSupportForPRCERegex() || $this->getSupportForPOSIXRegex(); + return $this->getSupportForPCRERegex() || $this->getSupportForPOSIXRegex(); } } diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 142f94825..ce21a5f4d 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2236,7 +2236,7 @@ public function getSupportForTrigramIndex(): bool return false; } - public function getSupportForPRCERegex(): bool + public function getSupportForPCRERegex(): bool { return false; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 91f57fa41..1e0fa6930 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2746,7 +2746,7 @@ public function getSupportForGetConnectionId(): bool * * @return bool */ - public function getSupportForPRCERegex(): bool + public function getSupportForPCRERegex(): bool { return true; } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 76cb5055d..1e61004a9 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -365,7 +365,7 @@ public function getSupportForFulltextWildcardIndex(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function getSupportForPRCERegex(): bool + public function getSupportForPCRERegex(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index bfce1cded..2d5b9ff3b 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2119,7 +2119,7 @@ public function getSupportForVectors(): bool return true; } - public function getSupportForPRCERegex(): bool + public function getSupportForPCRERegex(): bool { return false; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index dc7cfc83a..6d00bb90a 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1883,7 +1883,7 @@ public function getSupportForAlterLocks(): bool * * @return bool */ - public function getSupportForPRCERegex(): bool + public function getSupportForPCRERegex(): bool { return false; } diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 46a2a7fb4..a70bc39f4 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -6564,7 +6564,7 @@ public function testFindRegex(): void } // Determine regex support type - $supportsPCRE = $database->getAdapter()->getSupportForPRCERegex(); + $supportsPCRE = $database->getAdapter()->getSupportForPCRERegex(); $supportsPOSIX = $database->getAdapter()->getSupportForPOSIXRegex(); // Determine word boundary pattern based on support From e0e2b9d085b628aa824cc0c390bb2a4ffba56c4b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 31 Dec 2025 14:35:37 +0530 Subject: [PATCH 10/10] fixed mariadb support methods --- src/Database/Adapter/MariaDB.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index ce21a5f4d..2201ecc09 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -2238,7 +2238,7 @@ public function getSupportForTrigramIndex(): bool public function getSupportForPCRERegex(): bool { - return false; + return true; } public function getSupportForPOSIXRegex(): bool