diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index aa8276b90..ddfd75957 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1696,39 +1696,69 @@ protected function getSQLCondition(Query $query, array &$binds): string return "MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE)"; + case Query::TYPE_NOT_SEARCH: + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); + + return "NOT (MATCH({$alias}.{$attribute}) AGAINST (:{$placeholder}_0 IN BOOLEAN MODE))"; + case Query::TYPE_BETWEEN: $binds[":{$placeholder}_0"] = $query->getValues()[0]; $binds[":{$placeholder}_1"] = $query->getValues()[1]; return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_NOT_BETWEEN: + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + + return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: if ($this->getSupportForJSONOverlaps() && $query->onArray()) { $binds[":{$placeholder}_0"] = json_encode($query->getValues()); - return "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; + $isNot = $query->getMethod() === Query::TYPE_NOT_CONTAINS; + return $isNot + ? "NOT (JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0))" + : "JSON_OVERLAPS({$alias}.{$attribute}, :{$placeholder}_0)"; } // no break! continue to default case default: $conditions = []; + $isNotQuery = in_array($query->getMethod(), [ + Query::TYPE_NOT_STARTS_WITH, + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_NOT_CONTAINS + ]); + foreach ($query->getValues() as $key => $value) { $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; $binds[":{$placeholder}_{$key}"] = $value; - $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + + if ($isNotQuery) { + $conditions[] = "{$alias}.{$attribute} NOT {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + } else { + $conditions[] = "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())} :{$placeholder}_{$key}"; + } } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNotQuery ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; } } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 5430ba17f..29592122c 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1794,36 +1794,67 @@ protected function getSQLCondition(Query $query, array &$binds): string $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); return "to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0)"; + case Query::TYPE_NOT_SEARCH: + $binds[":{$placeholder}_0"] = $this->getFulltextValue($query->getValue()); + return "NOT (to_tsvector(regexp_replace({$attribute}, '[^\w]+',' ','g')) @@ websearch_to_tsquery(:{$placeholder}_0))"; + case Query::TYPE_BETWEEN: $binds[":{$placeholder}_0"] = $query->getValues()[0]; $binds[":{$placeholder}_1"] = $query->getValues()[1]; return "{$alias}.{$attribute} BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_NOT_BETWEEN: + $binds[":{$placeholder}_0"] = $query->getValues()[0]; + $binds[":{$placeholder}_1"] = $query->getValues()[1]; + return "{$alias}.{$attribute} NOT BETWEEN :{$placeholder}_0 AND :{$placeholder}_1"; + case Query::TYPE_IS_NULL: case Query::TYPE_IS_NOT_NULL: return "{$alias}.{$attribute} {$this->getSQLOperator($query->getMethod())}"; case Query::TYPE_CONTAINS: - $operator = $query->onArray() ? '@>' : null; + case Query::TYPE_NOT_CONTAINS: + if ($query->onArray()) { + $operator = '@>'; + } else { + $operator = null; + } // no break default: $conditions = []; $operator = $operator ?? $this->getSQLOperator($query->getMethod()); + $isNotQuery = in_array($query->getMethod(), [ + Query::TYPE_NOT_STARTS_WITH, + Query::TYPE_NOT_ENDS_WITH, + Query::TYPE_NOT_CONTAINS + ]); foreach ($query->getValues() as $key => $value) { $value = match ($query->getMethod()) { Query::TYPE_STARTS_WITH => $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_STARTS_WITH => $this->escapeWildcards($value) . '%', Query::TYPE_ENDS_WITH => '%' . $this->escapeWildcards($value), + Query::TYPE_NOT_ENDS_WITH => '%' . $this->escapeWildcards($value), Query::TYPE_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', + Query::TYPE_NOT_CONTAINS => $query->onArray() ? \json_encode($value) : '%' . $this->escapeWildcards($value) . '%', default => $value }; $binds[":{$placeholder}_{$key}"] = $value; - $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; + + if ($isNotQuery && $query->onArray()) { + // For array NOT queries, wrap the entire condition in NOT() + $conditions[] = "NOT ({$alias}.{$attribute} {$operator} :{$placeholder}_{$key})"; + } elseif ($isNotQuery && !$query->onArray()) { + $conditions[] = "{$alias}.{$attribute} NOT {$operator} :{$placeholder}_{$key}"; + } else { + $conditions[] = "{$alias}.{$attribute} {$operator} :{$placeholder}_{$key}"; + } } - return empty($conditions) ? '' : '(' . implode(' OR ', $conditions) . ')'; + $separator = $isNotQuery ? ' AND ' : ' OR '; + return empty($conditions) ? '' : '(' . implode($separator, $conditions) . ')'; } } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 7c06c337b..5eeb793b9 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -1501,6 +1501,9 @@ protected function getSQLOperator(string $method): string case Query::TYPE_STARTS_WITH: case Query::TYPE_ENDS_WITH: case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_STARTS_WITH: + case Query::TYPE_NOT_ENDS_WITH: + case Query::TYPE_NOT_CONTAINS: return $this->getLikeOperator(); default: throw new DatabaseException('Unknown method: ' . $method); diff --git a/src/Database/Query.php b/src/Database/Query.php index 90c15914f..e354ab96e 100644 --- a/src/Database/Query.php +++ b/src/Database/Query.php @@ -15,12 +15,17 @@ class Query public const TYPE_GREATER = 'greaterThan'; public const TYPE_GREATER_EQUAL = 'greaterThanEqual'; public const TYPE_CONTAINS = 'contains'; + public const TYPE_NOT_CONTAINS = 'notContains'; public const TYPE_SEARCH = 'search'; + public const TYPE_NOT_SEARCH = 'notSearch'; public const TYPE_IS_NULL = 'isNull'; public const TYPE_IS_NOT_NULL = 'isNotNull'; public const TYPE_BETWEEN = 'between'; + public const TYPE_NOT_BETWEEN = 'notBetween'; public const TYPE_STARTS_WITH = 'startsWith'; + public const TYPE_NOT_STARTS_WITH = 'notStartsWith'; public const TYPE_ENDS_WITH = 'endsWith'; + public const TYPE_NOT_ENDS_WITH = 'notEndsWith'; public const TYPE_SELECT = 'select'; @@ -48,12 +53,17 @@ class Query self::TYPE_GREATER, self::TYPE_GREATER_EQUAL, self::TYPE_CONTAINS, + self::TYPE_NOT_CONTAINS, self::TYPE_SEARCH, + self::TYPE_NOT_SEARCH, self::TYPE_IS_NULL, self::TYPE_IS_NOT_NULL, self::TYPE_BETWEEN, + self::TYPE_NOT_BETWEEN, self::TYPE_STARTS_WITH, + self::TYPE_NOT_STARTS_WITH, self::TYPE_ENDS_WITH, + self::TYPE_NOT_ENDS_WITH, self::TYPE_SELECT, self::TYPE_ORDER_DESC, self::TYPE_ORDER_ASC, @@ -206,7 +216,9 @@ public static function isMethod(string $value): bool self::TYPE_GREATER, self::TYPE_GREATER_EQUAL, self::TYPE_CONTAINS, + self::TYPE_NOT_CONTAINS, self::TYPE_SEARCH, + self::TYPE_NOT_SEARCH, self::TYPE_ORDER_ASC, self::TYPE_ORDER_DESC, self::TYPE_LIMIT, @@ -216,8 +228,11 @@ public static function isMethod(string $value): bool self::TYPE_IS_NULL, self::TYPE_IS_NOT_NULL, self::TYPE_BETWEEN, + self::TYPE_NOT_BETWEEN, self::TYPE_STARTS_WITH, + self::TYPE_NOT_STARTS_WITH, self::TYPE_ENDS_WITH, + self::TYPE_NOT_ENDS_WITH, self::TYPE_OR, self::TYPE_AND, self::TYPE_SELECT => true, @@ -429,6 +444,18 @@ public static function contains(string $attribute, array $values): self return new self(self::TYPE_CONTAINS, $attribute, $values); } + /** + * Helper method to create Query with notContains method + * + * @param string $attribute + * @param array $values + * @return Query + */ + public static function notContains(string $attribute, array $values): self + { + return new self(self::TYPE_NOT_CONTAINS, $attribute, $values); + } + /** * Helper method to create Query with between method * @@ -442,6 +469,19 @@ public static function between(string $attribute, string|int|float|bool $start, return new self(self::TYPE_BETWEEN, $attribute, [$start, $end]); } + /** + * Helper method to create Query with notBetween method + * + * @param string $attribute + * @param string|int|float|bool $start + * @param string|int|float|bool $end + * @return Query + */ + public static function notBetween(string $attribute, string|int|float|bool $start, string|int|float|bool $end): self + { + return new self(self::TYPE_NOT_BETWEEN, $attribute, [$start, $end]); + } + /** * Helper method to create Query with search method * @@ -454,6 +494,18 @@ public static function search(string $attribute, string $value): self return new self(self::TYPE_SEARCH, $attribute, [$value]); } + /** + * Helper method to create Query with notSearch method + * + * @param string $attribute + * @param string $value + * @return Query + */ + public static function notSearch(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_SEARCH, $attribute, [$value]); + } + /** * Helper method to create Query with select method * @@ -558,11 +610,21 @@ public static function startsWith(string $attribute, string $value): self return new self(self::TYPE_STARTS_WITH, $attribute, [$value]); } + public static function notStartsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_STARTS_WITH, $attribute, [$value]); + } + public static function endsWith(string $attribute, string $value): self { return new self(self::TYPE_ENDS_WITH, $attribute, [$value]); } + public static function notEndsWith(string $attribute, string $value): self + { + return new self(self::TYPE_NOT_ENDS_WITH, $attribute, [$value]); + } + /** * @param array $queries * @return Query diff --git a/src/Database/Validator/Queries.php b/src/Database/Validator/Queries.php index b1d67aad0..66d16bb62 100644 --- a/src/Database/Validator/Queries.php +++ b/src/Database/Validator/Queries.php @@ -93,12 +93,17 @@ public function isValid($value): bool Query::TYPE_GREATER, Query::TYPE_GREATER_EQUAL, Query::TYPE_SEARCH, + Query::TYPE_NOT_SEARCH, 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_CONTAINS, + Query::TYPE_NOT_CONTAINS, Query::TYPE_ENDS_WITH, + Query::TYPE_NOT_ENDS_WITH, Query::TYPE_AND, Query::TYPE_OR => Base::METHOD_TYPE_FILTER, default => '', diff --git a/src/Database/Validator/Query/Filter.php b/src/Database/Validator/Query/Filter.php index 9c2533558..272efc461 100644 --- a/src/Database/Validator/Query/Filter.php +++ b/src/Database/Validator/Query/Filter.php @@ -181,16 +181,17 @@ protected function isValidAttributeAndValues(string $attribute, array $values, s if ( !$array && - $method === Query::TYPE_CONTAINS && + in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS]) && $attributeSchema['type'] !== Database::VAR_STRING ) { - $this->message = 'Cannot query contains on attribute "' . $attribute . '" because it is not an array or string.'; + $queryType = $method === Query::TYPE_NOT_CONTAINS ? 'notContains' : 'contains'; + $this->message = 'Cannot query ' . $queryType . ' on attribute "' . $attribute . '" because it is not an array or string.'; return false; } if ( $array && - !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) + !in_array($method, [Query::TYPE_CONTAINS, Query::TYPE_NOT_CONTAINS, Query::TYPE_IS_NULL, Query::TYPE_IS_NOT_NULL]) ) { $this->message = 'Cannot query '. $method .' on attribute "' . $attribute . '" because it is an array.'; return false; @@ -233,6 +234,7 @@ public function isValid($value): bool switch ($method) { case Query::TYPE_EQUAL: case Query::TYPE_CONTAINS: + case Query::TYPE_NOT_CONTAINS: if ($this->isEmpty($value->getValues())) { $this->message = \ucfirst($method) . ' queries require at least one value.'; return false; @@ -246,8 +248,11 @@ public function isValid($value): bool case Query::TYPE_GREATER: case Query::TYPE_GREATER_EQUAL: case Query::TYPE_SEARCH: + case Query::TYPE_NOT_SEARCH: case Query::TYPE_STARTS_WITH: + case Query::TYPE_NOT_STARTS_WITH: case Query::TYPE_ENDS_WITH: + case Query::TYPE_NOT_ENDS_WITH: if (count($value->getValues()) != 1) { $this->message = \ucfirst($method) . ' queries require exactly one value.'; return false; @@ -256,6 +261,7 @@ public function isValid($value): bool return $this->isValidAttributeAndValues($attribute, $value->getValues(), $method); case Query::TYPE_BETWEEN: + case Query::TYPE_NOT_BETWEEN: if (count($value->getValues()) != 2) { $this->message = \ucfirst($method) . ' queries require exactly two values.'; return false; diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 58334135d..0ce664769 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -3030,6 +3030,316 @@ public function testFindEndsWith(): void $this->assertEquals(1, count($documents)); } + public function testFindNotContains(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + if (!$database->getAdapter()->getSupportForQueryContains()) { + $this->expectNotToPerformAssertions(); + return; + } + + // Test notContains with array attributes - should return documents that don't contain specified genres + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics']) + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 with 'comics' genre + + // Test notContains with multiple values (AND logic - exclude documents containing ANY of these) + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics', 'kids']), + ]); + + $this->assertEquals(2, count($documents)); // Movies that have neither 'comics' nor 'kids' + + // Test notContains with non-existent genre - should return all documents + $documents = $database->find('movies', [ + Query::notContains('genres', ['non-existent']), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notContains with string attribute (substring search) + $documents = $database->find('movies', [ + Query::notContains('name', ['Captain']) + ]); + $this->assertEquals(4, count($documents)); // All movies except those containing 'Captain' + + // Test notContains combined with other queries (AND logic) + $documents = $database->find('movies', [ + Query::notContains('genres', ['comics']), + Query::greaterThan('year', 2000) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of movies without 'comics' and after 2000 + + // Test notContains with case sensitivity + $documents = $database->find('movies', [ + Query::notContains('genres', ['COMICS']) // Different case + ]); + $this->assertEquals(6, count($documents)); // All movies since case doesn't match + + // Test error handling for invalid attribute type + try { + $database->find('movies', [ + Query::notContains('price', [10.5]), + ]); + $this->fail('Failed to throw exception'); + } catch (Throwable $e) { + $this->assertEquals('Invalid query: Cannot query notContains on attribute "price" because it is not an array or string.', $e->getMessage()); + $this->assertTrue($e instanceof DatabaseException); + } + } + + public function testFindNotSearch(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Only test if fulltext search is supported + if ($this->getDatabase()->getAdapter()->getSupportForFulltextIndex()) { + // Ensure fulltext index exists (may already exist from previous tests) + try { + $database->createIndex('movies', 'name', Database::INDEX_FULLTEXT, ['name']); + } catch (Throwable $e) { + // Index may already exist, ignore duplicate error + if (!str_contains($e->getMessage(), 'already exists')) { + throw $e; + } + } + + // Test notSearch - should return documents that don't match the search term + $documents = $database->find('movies', [ + Query::notSearch('name', 'captain'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 with 'captain' in name + + // Test notSearch with term that doesn't exist - should return all documents + $documents = $database->find('movies', [ + Query::notSearch('name', 'nonexistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notSearch with partial term + if ($this->getDatabase()->getAdapter()->getSupportForFulltextWildCardIndex()) { + $documents = $database->find('movies', [ + Query::notSearch('name', 'cap'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except those matching 'cap' + } + + // Test notSearch with empty string - should return all documents + $documents = $database->find('movies', [ + Query::notSearch('name', ''), + ]); + $this->assertEquals(6, count($documents)); // All movies since empty search matches nothing + + // Test notSearch combined with other filters + $documents = $database->find('movies', [ + Query::notSearch('name', 'captain'), + Query::lessThan('year', 2010) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-captain movies before 2010 + + // Test notSearch with special characters + $documents = $database->find('movies', [ + Query::notSearch('name', '@#$%'), + ]); + $this->assertEquals(6, count($documents)); // All movies since special chars don't match + } + + $this->assertEquals(true, true); // Test must do an assertion + } + + public function testFindNotStartsWith(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notStartsWith - should return documents that don't start with 'Work' + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'Work'), + ]); + + $this->assertEquals(4, count($documents)); // All movies except the 2 starting with 'Work' + + // Test notStartsWith with non-existent prefix - should return all documents + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'NonExistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notStartsWith with wildcard characters (should treat them literally) + if ($this->getDatabase()->getAdapter() instanceof SQL) { + $documents = $database->find('movies', [ + Query::notStartsWith('name', '%ork'), + ]); + } else { + $documents = $database->find('movies', [ + Query::notStartsWith('name', '.*ork'), + ]); + } + + $this->assertEquals(6, count($documents)); // Should return all since no movie starts with these patterns + + // Test notStartsWith with empty string - should return no documents (all strings start with empty) + $documents = $database->find('movies', [ + Query::notStartsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings start with empty string + + // Test notStartsWith with single character + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'C'), + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // Movies not starting with 'C' + + // Test notStartsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'work'), // lowercase vs 'Work' + ]); + $this->assertGreaterThanOrEqual(4, count($documents)); // May match case-insensitively + + // Test notStartsWith combined with other queries + $documents = $database->find('movies', [ + Query::notStartsWith('name', 'Work'), + Query::equal('year', [2006]) + ]); + $this->assertLessThanOrEqual(4, count($documents)); // Subset of non-Work movies from 2006 + } + + public function testFindNotEndsWith(): void + { + /** @var Database $database */ + $database = static::getDatabase(); + + // Test notEndsWith - should return documents that don't end with 'Marvel' + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'Marvel'), + ]); + + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'Marvel' + + // Test notEndsWith with non-existent suffix - should return all documents + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'NonExistent'), + ]); + + $this->assertEquals(6, count($documents)); + + // Test notEndsWith with partial suffix + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'vel'), + ]); + + $this->assertEquals(5, count($documents)); // All movies except the 1 ending with 'vel' (from 'Marvel') + + // Test notEndsWith with empty string - should return no documents (all strings end with empty) + $documents = $database->find('movies', [ + Query::notEndsWith('name', ''), + ]); + $this->assertEquals(0, count($documents)); // No documents since all strings end with empty string + + // Test notEndsWith with single character + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'l'), + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // Movies not ending with 'l' + + // Test notEndsWith with case sensitivity (may be case-insensitive depending on DB) + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'marvel'), // lowercase vs 'Marvel' + ]); + $this->assertGreaterThanOrEqual(5, count($documents)); // May match case-insensitively + + // Test notEndsWith combined with limit + $documents = $database->find('movies', [ + Query::notEndsWith('name', 'Marvel'), + Query::limit(3) + ]); + $this->assertEquals(3, count($documents)); // Limited to 3 results + $this->assertLessThanOrEqual(5, count($documents)); // But still excluding Marvel movies + } + + public function testFindNotBetween(): 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)); + + // 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 + + // 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 */ diff --git a/tests/unit/QueryTest.php b/tests/unit/QueryTest.php index d9ad6cd93..c0c17e59f 100644 --- a/tests/unit/QueryTest.php +++ b/tests/unit/QueryTest.php @@ -85,6 +85,37 @@ public function testCreate(): void $this->assertEquals(Query::TYPE_IS_NOT_NULL, $query->getMethod()); $this->assertEquals('title', $query->getAttribute()); $this->assertEquals([], $query->getValues()); + + // Test new NOT query types + $query = Query::notContains('tags', ['test', 'example']); + + $this->assertEquals(Query::TYPE_NOT_CONTAINS, $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['test', 'example'], $query->getValues()); + + $query = Query::notSearch('content', 'keyword'); + + $this->assertEquals(Query::TYPE_NOT_SEARCH, $query->getMethod()); + $this->assertEquals('content', $query->getAttribute()); + $this->assertEquals(['keyword'], $query->getValues()); + + $query = Query::notStartsWith('title', 'prefix'); + + $this->assertEquals(Query::TYPE_NOT_STARTS_WITH, $query->getMethod()); + $this->assertEquals('title', $query->getAttribute()); + $this->assertEquals(['prefix'], $query->getValues()); + + $query = Query::notEndsWith('url', '.html'); + + $this->assertEquals(Query::TYPE_NOT_ENDS_WITH, $query->getMethod()); + $this->assertEquals('url', $query->getAttribute()); + $this->assertEquals(['.html'], $query->getValues()); + + $query = Query::notBetween('score', 10, 20); + + $this->assertEquals(Query::TYPE_NOT_BETWEEN, $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + $this->assertEquals([10, 20], $query->getValues()); } /** @@ -138,6 +169,32 @@ public function testParse(): void $this->assertEquals('score', $query->getAttribute()); $this->assertEquals(8.5, $query->getValues()[0]); + // Test new NOT query types parsing + $query = Query::parse(Query::notContains('tags', ['unwanted', 'spam'])->toString()); + $this->assertEquals('notContains', $query->getMethod()); + $this->assertEquals('tags', $query->getAttribute()); + $this->assertEquals(['unwanted', 'spam'], $query->getValues()); + + $query = Query::parse(Query::notSearch('content', 'unwanted content')->toString()); + $this->assertEquals('notSearch', $query->getMethod()); + $this->assertEquals('content', $query->getAttribute()); + $this->assertEquals(['unwanted content'], $query->getValues()); + + $query = Query::parse(Query::notStartsWith('title', 'temp')->toString()); + $this->assertEquals('notStartsWith', $query->getMethod()); + $this->assertEquals('title', $query->getAttribute()); + $this->assertEquals(['temp'], $query->getValues()); + + $query = Query::parse(Query::notEndsWith('filename', '.tmp')->toString()); + $this->assertEquals('notEndsWith', $query->getMethod()); + $this->assertEquals('filename', $query->getAttribute()); + $this->assertEquals(['.tmp'], $query->getValues()); + + $query = Query::parse(Query::notBetween('score', 0, 50)->toString()); + $this->assertEquals('notBetween', $query->getMethod()); + $this->assertEquals('score', $query->getAttribute()); + $this->assertEquals([0, 50], $query->getValues()); + $query = Query::parse(Query::notEqual('director', 'null')->toString()); $this->assertEquals('notEqual', $query->getMethod()); $this->assertEquals('director', $query->getAttribute()); @@ -251,7 +308,13 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('greaterThan')); $this->assertTrue(Query::isMethod('greaterThanEqual')); $this->assertTrue(Query::isMethod('contains')); + $this->assertTrue(Query::isMethod('notContains')); $this->assertTrue(Query::isMethod('search')); + $this->assertTrue(Query::isMethod('notSearch')); + $this->assertTrue(Query::isMethod('startsWith')); + $this->assertTrue(Query::isMethod('notStartsWith')); + $this->assertTrue(Query::isMethod('endsWith')); + $this->assertTrue(Query::isMethod('notEndsWith')); $this->assertTrue(Query::isMethod('orderDesc')); $this->assertTrue(Query::isMethod('orderAsc')); $this->assertTrue(Query::isMethod('limit')); @@ -261,6 +324,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod('isNull')); $this->assertTrue(Query::isMethod('isNotNull')); $this->assertTrue(Query::isMethod('between')); + $this->assertTrue(Query::isMethod('notBetween')); $this->assertTrue(Query::isMethod('select')); $this->assertTrue(Query::isMethod('or')); $this->assertTrue(Query::isMethod('and')); @@ -272,7 +336,13 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(Query::TYPE_GREATER)); $this->assertTrue(Query::isMethod(Query::TYPE_GREATER_EQUAL)); $this->assertTrue(Query::isMethod(Query::TYPE_CONTAINS)); + $this->assertTrue(Query::isMethod(Query::TYPE_NOT_CONTAINS)); $this->assertTrue(Query::isMethod(QUERY::TYPE_SEARCH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_SEARCH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_STARTS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_STARTS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_ENDS_WITH)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_ENDS_WITH)); $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_ASC)); $this->assertTrue(Query::isMethod(QUERY::TYPE_ORDER_DESC)); $this->assertTrue(Query::isMethod(QUERY::TYPE_LIMIT)); @@ -282,6 +352,7 @@ public function testIsMethod(): void $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_IS_NOT_NULL)); $this->assertTrue(Query::isMethod(QUERY::TYPE_BETWEEN)); + $this->assertTrue(Query::isMethod(QUERY::TYPE_NOT_BETWEEN)); $this->assertTrue(Query::isMethod(QUERY::TYPE_SELECT)); $this->assertTrue(Query::isMethod(QUERY::TYPE_OR)); $this->assertTrue(Query::isMethod(QUERY::TYPE_AND)); @@ -289,4 +360,14 @@ public function testIsMethod(): void $this->assertFalse(Query::isMethod('invalid')); $this->assertFalse(Query::isMethod('lte ')); } + + public function testNewQueryTypesInTypesArray(): void + { + // Test that all new query types are included in the TYPES array + $this->assertContains(Query::TYPE_NOT_CONTAINS, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_SEARCH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_STARTS_WITH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_ENDS_WITH, Query::TYPES); + $this->assertContains(Query::TYPE_NOT_BETWEEN, Query::TYPES); + } } diff --git a/tests/unit/Validator/Query/FilterTest.php b/tests/unit/Validator/Query/FilterTest.php index 2465ea3e3..ff7bd2630 100644 --- a/tests/unit/Validator/Query/FilterTest.php +++ b/tests/unit/Validator/Query/FilterTest.php @@ -114,4 +114,75 @@ public function testMaxValuesCount(): void $this->assertFalse($this->validator->isValid(Query::equal('integer', $values))); $this->assertEquals('Query on attribute has greater than 100 values: integer', $this->validator->getDescription()); } + + public function testNotContains(): void + { + // Test valid notContains queries + $this->assertTrue($this->validator->isValid(Query::notContains('string', ['unwanted']))); + $this->assertTrue($this->validator->isValid(Query::notContains('string_array', ['spam', 'unwanted']))); + $this->assertTrue($this->validator->isValid(Query::notContains('integer_array', [100, 200]))); + + // Test invalid notContains queries (empty values) + $this->assertFalse($this->validator->isValid(Query::notContains('string', []))); + $this->assertEquals('NotContains queries require at least one value.', $this->validator->getDescription()); + } + + public function testNotSearch(): void + { + // Test valid notSearch queries + $this->assertTrue($this->validator->isValid(Query::notSearch('string', 'unwanted'))); + + // Test that arrays cannot use notSearch + $this->assertFalse($this->validator->isValid(Query::notSearch('string_array', 'unwanted'))); + $this->assertEquals('Cannot query notSearch on attribute "string_array" because it is an array.', $this->validator->getDescription()); + + // Test multiple values not allowed + $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_SEARCH, 'string', ['word1', 'word2']))); + $this->assertEquals('NotSearch queries require exactly one value.', $this->validator->getDescription()); + } + + public function testNotStartsWith(): void + { + // Test valid notStartsWith queries + $this->assertTrue($this->validator->isValid(Query::notStartsWith('string', 'temp'))); + + // Test that arrays cannot use notStartsWith + $this->assertFalse($this->validator->isValid(Query::notStartsWith('string_array', 'temp'))); + $this->assertEquals('Cannot query notStartsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); + + // Test multiple values not allowed + $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_STARTS_WITH, 'string', ['prefix1', 'prefix2']))); + $this->assertEquals('NotStartsWith queries require exactly one value.', $this->validator->getDescription()); + } + + public function testNotEndsWith(): void + { + // Test valid notEndsWith queries + $this->assertTrue($this->validator->isValid(Query::notEndsWith('string', '.tmp'))); + + // Test that arrays cannot use notEndsWith + $this->assertFalse($this->validator->isValid(Query::notEndsWith('string_array', '.tmp'))); + $this->assertEquals('Cannot query notEndsWith on attribute "string_array" because it is an array.', $this->validator->getDescription()); + + // Test multiple values not allowed + $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_ENDS_WITH, 'string', ['suffix1', 'suffix2']))); + $this->assertEquals('NotEndsWith queries require exactly one value.', $this->validator->getDescription()); + } + + public function testNotBetween(): void + { + // Test valid notBetween queries + $this->assertTrue($this->validator->isValid(Query::notBetween('integer', 0, 50))); + + // Test that arrays cannot use notBetween + $this->assertFalse($this->validator->isValid(Query::notBetween('integer_array', 1, 10))); + $this->assertEquals('Cannot query notBetween on attribute "integer_array" because it is an array.', $this->validator->getDescription()); + + // Test wrong number of values + $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10]))); + $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); + + $this->assertFalse($this->validator->isValid(new Query(Query::TYPE_NOT_BETWEEN, 'integer', [10, 20, 30]))); + $this->assertEquals('NotBetween queries require exactly two values.', $this->validator->getDescription()); + } }