diff --git a/app/realtime.php b/app/realtime.php index fcc5329982..eded4d79bc 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -29,6 +29,7 @@ use Utopia\Database\Adapter\Pool as DatabasePool; use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -473,9 +474,10 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $channels = $realtime->connections[$connection]['channels']; + $queries = $realtime->connections[$connection]['queries'] ?? []; $realtime->unsubscribe($connection); - $realtime->subscribe($projectId, $connection, $roles, $channels); + $realtime->subscribe($projectId, $connection, $roles, $channels, $queries); } } @@ -578,6 +580,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + try { + $queries = Realtime::convertQueries($request->getQuery('queries', [])); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $e->getMessage()); + } /** * Channels Check @@ -586,7 +593,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels'); } - $realtime->subscribe($project->getId(), $connection, $roles, $channels); + $realtime->subscribe($project->getId(), $connection, $roles, $channels, $queries); $realtime->connections[$connection]['authorization'] = $authorization; @@ -596,6 +603,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, 'type' => 'connected', 'data' => [ 'channels' => array_keys($channels), + 'queries' => $queries, 'user' => $user ] ])); @@ -730,7 +738,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re // Preserve authorization before subscribe overwrites the connection array $authorization = $realtime->connections[$connection]['authorization'] ?? null; - $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels); + $queries = $realtime->connections[$connection]['queries']; + $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels, $queries); // Restore authorization after subscribe if ($authorization !== null) { diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 35b8089668..9e03a7aaf7 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -4,10 +4,13 @@ namespace Appwrite\Messaging\Adapter; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; +use Appwrite\Utopia\Database\RuntimeQuery; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; class Realtime extends MessagingAdapter { @@ -51,9 +54,10 @@ class Realtime extends MessagingAdapter * @param mixed $identifier * @param array $roles * @param array $channels + * @param array $queries * @return void */ - public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels): void + public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels, array $queries = []): void { if (!isset($this->subscriptions[$projectId])) { // Init Project $this->subscriptions[$projectId] = []; @@ -72,7 +76,8 @@ class Realtime extends MessagingAdapter $this->connections[$identifier] = [ 'projectId' => $projectId, 'roles' => $roles, - 'channels' => $channels + 'channels' => $channels, + 'queries' => $queries ]; } @@ -206,7 +211,14 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - $receivers[$id] = 0; + $queries = $this->connections[$id]['queries'] ?? []; + $payload = $event['data']['payload'] ?? []; + if ( + empty($queries) || + !empty(RuntimeQuery::filter($queries, $payload)) + ) { + $receivers[$id] = 0; + } } break; } @@ -245,6 +257,34 @@ class Realtime extends MessagingAdapter return $channels; } + /** + * Converts the queries from the Query Params into an array. + * @param array $queries + * @return array + */ + public static function convertQueries(array $queries): array + { + $queries = Query::parseQueries($queries); + $stack = $queries; + $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES); + while (!empty($stack)) { + /** `@var` Query $query */ + $query = array_pop($stack); + $method = $query->getMethod(); + if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + $unsupportedMethod = $method; + throw new QueryException( + "Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}" + ); + } + if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR], true)) { + $stack = array_merge($stack, $query->getValues()); + } + } + + return $queries; + } + /** * Create channels array based on the event name and payload. * diff --git a/src/Appwrite/Utopia/Database/RuntimeQuery.php b/src/Appwrite/Utopia/Database/RuntimeQuery.php new file mode 100644 index 0000000000..025d424768 --- /dev/null +++ b/src/Appwrite/Utopia/Database/RuntimeQuery.php @@ -0,0 +1,121 @@ + $queries + * @param array $payload + */ + public static function filter(array $queries, array $payload): array + { + if (empty($queries)) { + return $payload; + } + // multiple queries follows and condition + foreach ($queries as $query) { + if (!self::evaluateFilter($query, $payload)) { + return []; + }; + } + return $payload; + } + + private static function evaluateFilter(Query $query, array $payload): bool + { + $attribute = $query->getAttribute(); + $method = $query->getMethod(); + $values = $query->getValues(); + + // during 'and' and 'or' attribute will not be present + switch ($method) { + case Query::TYPE_AND: + // All subqueries must evaluate to true + foreach ($query->getValues() as $subquery) { + if (!self::evaluateFilter($subquery, $payload)) { + return false; + } + } + return true; + + case Query::TYPE_OR: + // At least one subquery must evaluate to true + foreach ($query->getValues() as $subquery) { + if (self::evaluateFilter($subquery, $payload)) { + return true; + } + } + return false; + } + + $hasAttribute = \array_key_exists($attribute, $payload); + if (!$hasAttribute) { + return false; + } + + // null can be a value as well + $payloadAttributeValue = $payload[$attribute]; + switch ($method) { + case Query::TYPE_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); + + case Query::TYPE_NOT_EQUAL: + return !self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); + + case Query::TYPE_LESSER: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value); + + case Query::TYPE_LESSER_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue <= $value); + + case Query::TYPE_GREATER: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue > $value); + + case Query::TYPE_GREATER_EQUAL: + return self::anyMatch($values, fn ($value) => $payloadAttributeValue >= $value); + + // attribute must be present and should be explicitly null + case Query::TYPE_IS_NULL: + return $payloadAttributeValue === null; + + case Query::TYPE_IS_NOT_NULL: + return $payloadAttributeValue !== null; + + default: + throw new \InvalidArgumentException( + "Unsupported query method: {$method}" + ); + } + } + + private static function anyMatch(array $values, callable $fn): bool + { + foreach ($values as $value) { + if ($fn($value)) { + return true; + } + } + return false; + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeBase.php b/tests/e2e/Services/Realtime/RealtimeBase.php index 89bd1898c4..ea5c3d710f 100644 --- a/tests/e2e/Services/Realtime/RealtimeBase.php +++ b/tests/e2e/Services/Realtime/RealtimeBase.php @@ -10,7 +10,8 @@ trait RealtimeBase private function getWebsocket( array $channels = [], array $headers = [], - string $projectId = null + string $projectId = null, + array $queries = [] ): WebSocketClient { if (is_null($projectId)) { $projectId = $this->getProject()['$id']; @@ -19,6 +20,7 @@ trait RealtimeBase $query = [ "project" => $projectId, "channels" => $channels, + "queries" => $queries ]; return new WebSocketClient( diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php new file mode 100644 index 0000000000..068736561e --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -0,0 +1,1547 @@ +getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that matches current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$userId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Update account name - should receive event (matches query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($name, $event['data']['payload']['name']); + + $client->close(); + + + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe with query that does NOT match current user + $client = $this->getWebsocket(['account'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$userId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Update account name - should NOT receive event (doesn't match query) + $name = "Test User " . uniqid(); + $this->client->call(Client::METHOD_PATCH, '/account/name', array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]), [ + 'name' => $name + ]); + + // Should timeout - no event should be received + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testDatabaseChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $targetDocumentId = ID::unique(); + + // Subscribe with query for specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocumentId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with matching ID - should receive event + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocumentId, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $otherDocumentId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NotEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $excludedDocumentId = ID::unique(); + + // Subscribe with query that excludes specific document ID + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::notEqual('$id', [$excludedDocumentId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with different ID - should receive event + $allowedDocumentId = ID::unique(); + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $allowedDocumentId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($allowedDocumentId, $event['data']['payload']['$id']); + + // Create document with excluded ID - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $excludedDocumentId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for score > 50 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThan('score', 50)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with score > 50 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 75 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(75, $event['data']['payload']['score']); + + // Create document with score <= 50 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'score' => 30 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserThan Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for age < 18 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThan('age', 18)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with age < 18 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(15, $event['data']['payload']['age']); + + // Create document with age >= 18 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'age' => 25 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'GreaterEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for priority >= 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::greaterThanEqual('priority', 5)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with priority = 5 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 5 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(5, $event['data']['payload']['priority']); + + // Create document with priority > 5 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with priority < 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'LesserEqual Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'level', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for level <= 10 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::lessThanEqual('level', 10)->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with level = 10 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 10 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(10, $event['data']['payload']['level']); + + // Create document with level < 10 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 7 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals(7, $event['data']['payload']['level']); + + // Create document with level > 10 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'level' => 15 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'description', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for description IS NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNull('description')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document without description - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => null + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + + // Create document with description - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'description' => 'Has description' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsNotNull Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with query for email IS NOT NULL + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('email')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with email - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'email' => 'test@example.com' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('test@example.com', $event['data']['payload']['email']); + + // Create document without email - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'And Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with AND query: status = 'active' AND priority > 5 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('priority', 5) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document matching both conditions - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 8 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('active', $event['data']['payload']['status']); + $this->assertEquals(8, $event['data']['payload']['priority']); + + // Create document with status = 'active' but priority <= 5 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'active', + 'priority' => 3 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with priority > 5 but status != 'active' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'status' => 'inactive', + 'priority' => 9 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Or Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'type', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + // Subscribe with OR query: type = 'urgent' OR type = 'critical' + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('type', ['urgent']), + Query::equal('type', ['critical']) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with type = 'urgent' - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'urgent' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('urgent', $event['data']['payload']['type']); + + // Create document with type = 'critical' - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'critical' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('critical', $event['data']['payload']['type']); + + // Create document with type = 'normal' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'type' => 'normal' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Complex Query Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + ]); + + sleep(2); + + // Subscribe with complex query: (category = 'premium' OR category = 'vip') AND score >= 80 + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::or([ + Query::equal('category', ['premium']), + Query::equal('category', ['vip']) + ]), + Query::greaterThanEqual('score', 80) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with category = 'premium' and score >= 80 - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('premium', $event['data']['payload']['category']); + $this->assertEquals(85, $event['data']['payload']['score']); + + // Create document with category = 'vip' and score >= 80 - should receive event + $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'vip', + 'score' => 90 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals('vip', $event['data']['payload']['category']); + $this->assertEquals(90, $event['data']['payload']['score']); + + // Create document with category = 'premium' but score < 80 - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'premium', + 'score' => 70 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with score >= 80 but category != 'premium' or 'vip' - should NOT receive event + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'category' => 'standard', + 'score' => 85 + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testFilesChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create bucket + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'bucketId' => ID::unique(), + 'name' => 'Query Test Bucket', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ] + ]); + $bucketId = $bucket['body']['$id']; + + $targetFileId = ID::unique(); + + // Subscribe with query for specific file ID + $client = $this->getWebsocket(['files'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetFileId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create file with matching ID - should receive event + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $targetFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetFileId, $event['data']['payload']['$id']); + + // Create file with different ID - should NOT receive event + $otherFileId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'fileId' => $otherFileId, + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/logo.png'), 'image/png', 'logo2.png'), + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithAndLogic() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Setup database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Multiple Queries Test DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Test Collection', + 'permissions' => [ + Permission::create(Role::user($user['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 256, + 'required' => false, + ]); + + sleep(2); + + $targetDocId = ID::unique(); + + // Subscribe with multiple queries (AND logic - ALL queries must match for event to be received) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetDocId])->toString(), + Query::equal('status', ['active'])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document matching BOTH queries - should receive event + $document1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetDocId, $event['data']['payload']['$id']); + $this->assertEquals('active', $event['data']['payload']['status']); + + // Create document with matching ID but wrong status - should NOT receive event (only one query matches) + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $targetDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (ID matches but status does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document with matching status but wrong ID - should NOT receive event (only one query matches) + $otherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $otherDocId, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (status matches but ID does not)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + // Create document matching NEITHER query - should NOT receive event + $anotherDocId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'documentId' => $anotherDocId, + 'data' => [ + 'status' => 'inactive' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered (neither query matches)'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testInvalidQueryShouldNotSubscribe() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Test 1: Simple invalid query method (contains is not allowed) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::contains('status', ['active'])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('contains', $response['data']['message']); + + // Test 2: Invalid query method in nested AND query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::search('name', 'test') // search is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('search', $response['data']['message']); + + // Test 3: Invalid query method in nested OR query + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::or([ + Query::equal('status', ['active']), + Query::between('score', 0, 100) // between is not allowed + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('between', $response['data']['message']); + + // Test 4: Deeply nested invalid query (AND -> OR -> invalid) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::equal('status', ['active']), + Query::or([ + Query::greaterThan('score', 50), + Query::startsWith('name', 'test') // startsWith is not allowed + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + $this->assertStringContainsString('startsWith', $response['data']['message']); + + // Test 5: Multiple invalid queries in nested structure + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::and([ + Query::contains('tags', ['important']), // contains is not allowed + Query::or([ + Query::endsWith('email', '@example.com'), // endsWith is not allowed + Query::equal('status', ['active']) + ]) + ])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('error', $response['type']); + $this->assertStringContainsString('not supported in Realtime queries', $response['data']['message']); + // Should catch the first invalid method encountered + $this->assertTrue( + str_contains($response['data']['message'], 'contains') || + str_contains($response['data']['message'], 'endsWith') + ); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index c6a1686864..bd746f69f8 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -692,8 +692,8 @@ class RealtimeCustomClientTest extends Scope $client = $this->getWebsocket(['documents', 'collections'], [ 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session - ]); + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null); $response = json_decode($client->receive(), true); @@ -2962,7 +2962,7 @@ class RealtimeCustomClientTest extends Scope sleep(1); try { - $client->receive(1); // 1 second timeout + $client->receive(); $this->fail('Should not receive any event after rollback'); } catch (TimeoutException $e) { // Expected - no event should be triggered diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php new file mode 100644 index 0000000000..7df1ca80eb --- /dev/null +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -0,0 +1,602 @@ + 'John', 'age' => 30]; + $result = RuntimeQuery::filter([], $payload); + $this->assertEquals($payload, $result); + } + + public function testFilterWithNoMatchingQuery(): void + { + $queries = [Query::equal('name', ['Jane'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testFilterWithMatchingQuery(): void + { + $queries = [Query::equal('name', ['John'])]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_EQUAL tests + public function testEqualMatch(): void + { + $query = Query::equal('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualNoMatch(): void + { + $query = Query::equal('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualMultipleValuesMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMultipleValuesNoMatch(): void + { + $query = Query::equal('status', ['active', 'pending', 'approved']); + $payload = ['status' => 'rejected']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEqualNumericValues(): void + { + $query = Query::equal('age', [30, 25, 35]); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualBooleanValues(): void + { + $query = Query::equal('active', [true]); + $payload = ['active' => true]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualMissingAttribute(): void + { + $query = Query::equal('missing', ['value']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_NOT_EQUAL tests + public function testNotEqualMatch(): void + { + $query = Query::notEqual('name', ['Jane']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testNotEqualNoMatch(): void + { + $query = Query::notEqual('name', ['John']); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testNotEqualMultipleValues(): void + { + // generally from the client side they will pass query strings via the realtime + // and Query::parse will be done first and parse doesn't allow multiple notEqual values + $query = Query::notEqual('status', ['rejected', 'cancelled']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + + $query = Query::notEqual('status', ['active', 'pending']); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_LESSER tests + public function testLesserMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserNoMatch(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualValue(): void + { + $query = Query::lessThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserMultipleValues(): void + { + // Note: Query::lessThan only accepts single value, but RuntimeQuery's anyMatch supports arrays + // This test uses a single value as Query class requires + $query = Query::lessThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserStringComparison(): void + { + $query = Query::lessThan('name', 'M'); + $payload = ['name' => 'A']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_LESSER_EQUAL tests + public function testLesserEqualMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualExactMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testLesserEqualNoMatch(): void + { + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testLesserEqualMultipleValues(): void + { + // Note: Query::lessThanEqual only accepts single value + $query = Query::lessThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER tests + public function testGreaterMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterNoMatch(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualValue(): void + { + $query = Query::greaterThan('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterMultipleValues(): void + { + // Note: Query::greaterThan only accepts single value + $query = Query::greaterThan('age', 20); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_GREATER_EQUAL tests + public function testGreaterEqualMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 35]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualExactMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testGreaterEqualNoMatch(): void + { + $query = Query::greaterThanEqual('age', 30); + $payload = ['age' => 25]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testGreaterEqualMultipleValues(): void + { + // Note: Query::greaterThanEqual only accepts single value + $query = Query::greaterThanEqual('age', 20); + $payload = ['age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_IS_NULL tests + public function testIsNullMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNullNoMatch(): void + { + $query = Query::isNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNullMissingAttribute(): void + { + $query = Query::isNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_IS_NOT_NULL tests + public function testIsNotNullMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => 'Some text']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testIsNotNullNoMatch(): void + { + $query = Query::isNotNull('description'); + $payload = ['description' => null]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testIsNotNullMissingAttribute(): void + { + $query = Query::isNotNull('missing'); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + // TYPE_AND tests + public function testAndAllMatch(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndOneFails(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndAllFail(): void + { + $query = Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testAndMultipleConditions(): void + { + $query = Query::and([ + Query::equal('status', ['active']), + Query::greaterThan('age', 18), + Query::isNotNull('email') + ]); + $payload = ['status' => 'active', 'age' => 25, 'email' => 'test@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testAndNestedAnd(): void + { + $query = Query::and([ + Query::equal('name', ['John']), + Query::and([ + Query::equal('age', [30]), + Query::equal('status', ['active']) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30, 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // TYPE_OR tests + public function testOrOneMatch(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('name', ['Jane']) + ]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllMatch(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]); + $payload = ['status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAllFail(): void + { + $query = Query::or([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testOrMultipleConditions(): void + { + $query = Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']), + Query::equal('status', ['approved']) + ]); + $payload = ['status' => 'pending']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrNestedOr(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::or([ + Query::equal('name', ['Jane']), + Query::equal('name', ['Bob']) + ]) + ]); + $payload = ['name' => 'Bob']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrWithDifferentAttributes(): void + { + $query = Query::or([ + Query::equal('name', ['John']), + Query::equal('email', ['john@example.com']) + ]); + $payload = ['name' => 'Jane', 'email' => 'john@example.com']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Complex combinations + public function testAndOrCombination(): void + { + $query = Query::and([ + Query::equal('type', ['user']), + Query::or([ + Query::equal('status', ['active']), + Query::equal('status', ['pending']) + ]) + ]); + $payload = ['type' => 'user', 'status' => 'active']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testOrAndCombination(): void + { + $query = Query::or([ + Query::and([ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]), + Query::and([ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]) + ]); + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + // Edge cases + public function testMultipleQueriesAllMatch(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesFirstMatches(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + // With AND logic, if first matches but second doesn't, should return empty + $this->assertEquals([], $result); + } + + public function testMultipleQueriesSecondMatches(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + // With AND logic, if second matches but first doesn't, should return empty + $this->assertEquals([], $result); + } + + public function testMultipleQueriesNoneMatch(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals([], $result); + } + + public function testEmptyPayload(): void + { + $query = Query::equal('name', ['John']); + $payload = []; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals([], $result); + } + + public function testEmptyAndQuery(): void + { + $query = Query::and([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty AND should return true (all conditions pass vacuously) + $this->assertEquals($payload, $result); + } + + public function testEmptyOrQuery(): void + { + $query = Query::or([]); + $payload = ['name' => 'John']; + $result = RuntimeQuery::filter([$query], $payload); + // Empty OR should return false (no conditions match) + $this->assertEquals([], $result); + } + + // Type-specific edge cases + public function testEqualWithZero(): void + { + $query = Query::equal('count', [0]); + $payload = ['count' => 0]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithEmptyString(): void + { + $query = Query::equal('name', ['']); + $payload = ['name' => '']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testEqualWithFalse(): void + { + $query = Query::equal('active', [false]); + $payload = ['active' => false]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithFloat(): void + { + $query = Query::greaterThan('score', 8.5); + $payload = ['score' => 9.2]; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } + + public function testComparisonWithStringNumbers(): void + { + $query = Query::lessThan('version', '10'); + $payload = ['version' => '9']; + $result = RuntimeQuery::filter([$query], $payload); + $this->assertEquals($payload, $result); + } +}