From b7e2606b9f68dfd096cfec5cd18a82216273264e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 22 Dec 2025 18:01:00 +0530 Subject: [PATCH 1/9] Enhance Realtime functionality with query support and improve tests - Updated Realtime adapter to handle queries during subscription. - Added query filtering capabilities in RuntimeQuery class. - Modified RealtimeBase and RealtimeCustomClientTest to support query parameters in WebSocket connections. - Improved test coverage for account and database channels with queries. --- app/realtime.php | 20 +-- src/Appwrite/Messaging/Adapter/Realtime.php | 45 ++++++- .../Utopia/Database/Query/RuntimeQuery.php | 114 ++++++++++++++++++ tests/e2e/Services/Realtime/RealtimeBase.php | 4 +- .../Realtime/RealtimeCustomClientTest.php | 81 ++++++++++++- 5 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 src/Appwrite/Utopia/Database/Query/RuntimeQuery.php diff --git a/app/realtime.php b/app/realtime.php index 31e6015d92..4bd105beb1 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -471,19 +471,20 @@ $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); } } $receivers = $realtime->getSubscribers($event); - if (App::isDevelopment() && !empty($receivers)) { - Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); - Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); - Console::log("[Debug][Worker {$workerId}] Event: " . $payload); - } + // if (App::isDevelopment() && !empty($receivers)) { + // Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); + // Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); + // Console::log("[Debug][Worker {$workerId}] Event: " . $payload); + // } $server->send( $receivers, @@ -576,6 +577,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); + $queries = Realtime::convertQueries($request->getQuery('queries', [])); /** * Channels Check @@ -584,7 +586,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; @@ -594,6 +596,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, 'type' => 'connected', 'data' => [ 'channels' => array_keys($channels), + 'queries' => array_keys($queries), 'user' => $user ] ])); @@ -724,11 +727,12 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $roles = $user->getRoles($database->getAuthorization()); $channels = Realtime::convertChannels(array_flip($realtime->connections[$connection]['channels']), $user->getId()); + $queries = $realtime->connections[$connection]['queries']; // Preserve authorization before subscribe overwrites the connection array $authorization = $realtime->connections[$connection]['authorization'] ?? null; - $realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels); + $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..562be00e33 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -2,12 +2,16 @@ namespace Appwrite\Messaging\Adapter; +use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; +use Appwrite\Utopia\Database\Query\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 +55,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 +77,8 @@ class Realtime extends MessagingAdapter $this->connections[$identifier] = [ 'projectId' => $projectId, 'roles' => $roles, - 'channels' => $channels + 'channels' => $channels, + 'queries' => $queries ]; } @@ -206,7 +212,9 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - $receivers[$id] = 0; + if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']))) { + $receivers[$id] = 0; + } } break; } @@ -217,6 +225,19 @@ class Realtime extends MessagingAdapter return array_keys($receivers); } + public function filterEventData(array $documents, array $queries): array + { + if (empty($queries)) { + return $documents; + } + $filteredDocuments = []; + foreach ($documents as $document) { + $doc = new Document((array) $doc); + } + + return $filteredDocuments; + } + /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. @@ -245,6 +266,24 @@ 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); + foreach ($queries as $query) { + if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { + // TODO: add better error message with which queries are allowed + throw new QueryException(Exception::REALTIME_POLICY_VIOLATION, 'Query not supported'); + } + } + + return $queries; + } + /** * Create channels array based on the event name and payload. * diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php new file mode 100644 index 0000000000..c887ca36d6 --- /dev/null +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -0,0 +1,114 @@ + $queries + * @param array $payload + */ + public static function filter(array $queries, array $payload): array + { + if (empty($queries)) { + return $payload; + } + foreach ($queries as $query) { + if (self::evaluateFilter($query, $payload)) { + return $payload; + }; + } + return []; + } + + private static function evaluateFilter(Query $query, array $payload): bool + { + $attribute = $query->getAttribute(); + $method = $query->getMethod(); + $values = $query->getValues(); + if (!\array_key_exists($attribute, $payload)) { + return false; + } + $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); + + case Query::TYPE_IS_NULL: + return $payloadAttributeValue === null; + + case Query::TYPE_IS_NOT_NULL: + return $payloadAttributeValue !== null; + + case Query::TYPE_AND: + foreach ($query->getValues() as $subquery) { + // if any evaluation gets to false then whole and is false + if (!self::evaluateFilter($subquery, $payload)) { + return false; + } + return true; + } + + // no break + case Query::TYPE_OR: + foreach ($query->getValues() as $subquery) { + // if any evaluation gets to true then whole or is true + if (self::evaluateFilter($subquery, $payload)) { + return true; + } + return false; + } + + // no break + 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/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index c6a1686864..b15389dd2f 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -12,6 +12,7 @@ use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use WebSocket\ConnectionException; use WebSocket\TimeoutException; @@ -124,6 +125,82 @@ class RealtimeCustomClientTest extends Scope $client->close(); } + public function testAccountChannelWithQueries() + { + $user = $this->getUser(); + $userId = $user['$id'] ?? ''; + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe to account channel with a simple query + $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->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + + // Channels still work as usual + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('account', $response['data']['channels']); + $this->assertContains('account.' . $userId, $response['data']['channels']); + + // Queries are echoed back in the connection payload + $this->assertArrayHasKey('queries', $response['data']); + $this->assertIsArray($response['data']['queries']); + $this->assertCount(1, $response['data']['queries']); + + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($userId, $response['data']['user']['$id']); + + $client->close(); + } + + public function testDatabaseChannelWithQueries() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Subscribe to database-related channels with queries + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', ['dummy-id'])->toString(), + Query::isNotNull('payload')->toString(), + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + + // Channels as in regular database test + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + + // Queries should be present + $this->assertArrayHasKey('queries', $response['data']); + $this->assertIsArray($response['data']['queries']); + $this->assertCount(2, $response['data']['queries']); + + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + $client->close(); + } + public function testPingPong() { $client = $this->getWebsocket(['files'], [ @@ -692,8 +769,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); From 39cf207df9a2b730cce9d70787dcf7fcf5f98280 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 18:56:55 +0530 Subject: [PATCH 2/9] re --- app/realtime.php | 10 +- .../Utopia/Database/Query/RuntimeQuery.php | 56 +- .../RealtimeCustomClientQueryTest.php | 1550 +++++++++++++++++ .../Realtime/RealtimeCustomClientTest.php | 2 +- .../Database/Query/RuntimeQueryTest.php | 589 +++++++ 5 files changed, 2179 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php create mode 100644 tests/unit/Utopia/Database/Query/RuntimeQueryTest.php diff --git a/app/realtime.php b/app/realtime.php index 4bd105beb1..b81ddf551c 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -480,11 +480,11 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $receivers = $realtime->getSubscribers($event); - // if (App::isDevelopment() && !empty($receivers)) { - // Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); - // Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); - // Console::log("[Debug][Worker {$workerId}] Event: " . $payload); - // } + if (App::isDevelopment() && !empty($receivers)) { + Console::log("[Debug][Worker {$workerId}] Receivers: " . count($receivers)); + Console::log("[Debug][Worker {$workerId}] Receivers Connection IDs: " . json_encode($receivers)); + Console::log("[Debug][Worker {$workerId}] Event: " . $payload); + } $server->send( $receivers, diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php index c887ca36d6..756245098f 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -46,16 +46,48 @@ class RuntimeQuery extends Query $attribute = $query->getAttribute(); $method = $query->getMethod(); $values = $query->getValues(); - if (!\array_key_exists($attribute, $payload)) { + + // during 'and' and 'or' attribute will not be present + if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR])) { + 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; + + default: + throw new \InvalidArgumentException( + "Unsupported query method: {$method}" + ); + } + } + + $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); + return !self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value); case Query::TYPE_LESSER: return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value); @@ -75,26 +107,6 @@ class RuntimeQuery extends Query case Query::TYPE_IS_NOT_NULL: return $payloadAttributeValue !== null; - case Query::TYPE_AND: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to false then whole and is false - if (!self::evaluateFilter($subquery, $payload)) { - return false; - } - return true; - } - - // no break - case Query::TYPE_OR: - foreach ($query->getValues() as $subquery) { - // if any evaluation gets to true then whole or is true - if (self::evaluateFilter($subquery, $payload)) { - return true; - } - return false; - } - - // no break default: throw new \InvalidArgumentException( "Unsupported query method: {$method}" diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php new file mode 100644 index 0000000000..303c6067be --- /dev/null +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -0,0 +1,1550 @@ +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 testExecutionChannelWithQuery() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + // Create function + $function = $this->client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'functionId' => ID::unique(), + 'name' => 'Test Function', + 'execute' => ['users'], + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'timeout' => 10, + ]); + $functionId = $function['body']['$id'] ?? ''; + + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'code' => $this->packageFunction('timeout'), + 'activate' => true + ]); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // Poll until deployment is built + $this->assertEventually(function () use ($function, $deploymentId, $projectId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('ready', $deployment['body']['status']); + }); + + // Subscribe with query for execution with response (not null) + $client = $this->getWebsocket(['executions'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::isNotNull('response')->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Execute function - should receive event when execution completes with response + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId + ], $this->getHeaders()), [ + 'async' => true + ]); + + // Wait for execution to complete + $event = json_decode($client->receive(), true); + if ($event['type'] === 'event' && isset($event['data']['payload']['response'])) { + $this->assertEquals('event', $event['type']); + $this->assertNotNull($event['data']['payload']['response']); + } + + $client->close(); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], []); + + $targetTeamId = ID::unique(); + + // Subscribe with query for specific team ID + $client = $this->getWebsocket(['teams'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$targetTeamId])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create team with matching ID - should receive event + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $targetTeamId, + 'name' => 'Query Test Team' + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($targetTeamId, $event['data']['payload']['$id']); + + // Create team with different ID - should NOT receive event + $otherTeamId = ID::unique(); + $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'teamId' => $otherTeamId, + 'name' => 'Other Team' + ]); + + try { + $client->receive(); + $this->fail('Expected TimeoutException - event should be filtered'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } + + public function testMultipleQueriesWithOrLogic() + { + $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); + + $docId1 = ID::unique(); + $docId2 = ID::unique(); + + // Subscribe with multiple queries (OR logic - any query matching returns event) + $client = $this->getWebsocket(['documents'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ], null, [ + Query::equal('$id', [$docId1])->toString(), + Query::equal('$id', [$docId2])->toString(), + ]); + + $response = json_decode($client->receive(), true); + $this->assertEquals('connected', $response['type']); + + // Create document with first ID - 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' => $docId1, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId1, $event['data']['payload']['$id']); + + // Create document with second ID - 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' => $docId2, + 'data' => [ + 'status' => 'active' + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ]); + + $event = json_decode($client->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertEquals($docId2, $event['data']['payload']['$id']); + + // Create document with different ID - should NOT receive event + $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'); + } catch (TimeoutException $e) { + $this->assertTrue(true); + } + + $client->close(); + } +} diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index b15389dd2f..112eed1ccd 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3039,7 +3039,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..2156d862a5 --- /dev/null +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -0,0 +1,589 @@ + '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 testMultipleQueriesFirstMatches(): void + { + $queries = [ + Query::equal('name', ['John']), + Query::equal('age', [25]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $result); + } + + public function testMultipleQueriesSecondMatches(): void + { + $queries = [ + Query::equal('name', ['Jane']), + Query::equal('age', [30]) + ]; + $payload = ['name' => 'John', 'age' => 30]; + $result = RuntimeQuery::filter($queries, $payload); + $this->assertEquals($payload, $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); + } +} From 881d96a6532bf65b9fbac9b16054267630e6a80d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 19:06:34 +0530 Subject: [PATCH 3/9] linting --- .../Realtime/RealtimeCustomClientTest.php | 77 ------------------- 1 file changed, 77 deletions(-) diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index 112eed1ccd..bd746f69f8 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -12,7 +12,6 @@ use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; -use Utopia\Database\Query; use WebSocket\ConnectionException; use WebSocket\TimeoutException; @@ -125,82 +124,6 @@ class RealtimeCustomClientTest extends Scope $client->close(); } - public function testAccountChannelWithQueries() - { - $user = $this->getUser(); - $userId = $user['$id'] ?? ''; - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe to account channel with a simple query - $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->assertArrayHasKey('type', $response); - $this->assertArrayHasKey('data', $response); - $this->assertEquals('connected', $response['type']); - $this->assertNotEmpty($response['data']); - - // Channels still work as usual - $this->assertCount(2, $response['data']['channels']); - $this->assertContains('account', $response['data']['channels']); - $this->assertContains('account.' . $userId, $response['data']['channels']); - - // Queries are echoed back in the connection payload - $this->assertArrayHasKey('queries', $response['data']); - $this->assertIsArray($response['data']['queries']); - $this->assertCount(1, $response['data']['queries']); - - $this->assertNotEmpty($response['data']['user']); - $this->assertEquals($userId, $response['data']['user']['$id']); - - $client->close(); - } - - public function testDatabaseChannelWithQueries() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Subscribe to database-related channels with queries - $client = $this->getWebsocket(['documents', 'collections'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', ['dummy-id'])->toString(), - Query::isNotNull('payload')->toString(), - ]); - - $response = json_decode($client->receive(), true); - - $this->assertArrayHasKey('type', $response); - $this->assertArrayHasKey('data', $response); - $this->assertEquals('connected', $response['type']); - $this->assertNotEmpty($response['data']); - - // Channels as in regular database test - $this->assertCount(2, $response['data']['channels']); - $this->assertContains('documents', $response['data']['channels']); - $this->assertContains('collections', $response['data']['channels']); - - // Queries should be present - $this->assertArrayHasKey('queries', $response['data']); - $this->assertIsArray($response['data']['queries']); - $this->assertCount(2, $response['data']['queries']); - - $this->assertNotEmpty($response['data']['user']); - $this->assertEquals($user['$id'], $response['data']['user']['$id']); - - $client->close(); - } - public function testPingPong() { $client = $this->getWebsocket(['files'], [ From 336bd4482672fa8bfe91414e5210654b0e75f30a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 20:10:00 +0530 Subject: [PATCH 4/9] fixed payload in adapter --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 562be00e33..e4acb677c6 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -212,7 +212,7 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']))) { + if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']['payload']))) { $receivers[$id] = 0; } } From 7e315f79ccc480bda8e0052c0c73640ac8d58783 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 20:50:05 +0530 Subject: [PATCH 5/9] refactor: improve query handling in Realtime adapter and update RuntimeQuery filter logic --- src/Appwrite/Messaging/Adapter/Realtime.php | 7 +- .../Utopia/Database/Query/RuntimeQuery.php | 1 + .../RealtimeCustomClientQueryTest.php | 122 ------------------ 3 files changed, 7 insertions(+), 123 deletions(-) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index e4acb677c6..43068a9d46 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -212,7 +212,12 @@ class Realtime extends MessagingAdapter /** * To prevent duplicates, we save the connections as array keys. */ - if (!empty(RuntimeQuery::filter($this->connections[$id]['queries'], $event['data']['payload']))) { + $queries = $this->connections[$id]['queries'] ?? []; + $payload = $event['data']['payload'] ?? []; + if ( + empty($queries) || + !empty(RuntimeQuery::filter($queries, $payload)) + ) { $receivers[$id] = 0; } } diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php index 756245098f..f97ba015ca 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php @@ -101,6 +101,7 @@ class RuntimeQuery extends Query 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; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 303c6067be..0272450245 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1307,128 +1307,6 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } - public function testExecutionChannelWithQuery() - { - $user = $this->getUser(); - $session = $user['session'] ?? ''; - $projectId = $this->getProject()['$id']; - - // Create function - $function = $this->client->call(Client::METHOD_POST, '/functions', [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ], [ - 'functionId' => ID::unique(), - 'name' => 'Test Function', - 'execute' => ['users'], - 'runtime' => 'node-22', - 'entrypoint' => 'index.js', - 'timeout' => 10, - ]); - $functionId = $function['body']['$id'] ?? ''; - - $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ - 'content-type' => 'multipart/form-data', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'code' => $this->packageFunction('timeout'), - 'activate' => true - ]); - $deploymentId = $deployment['body']['$id'] ?? ''; - - // Poll until deployment is built - $this->assertEventually(function () use ($function, $deploymentId, $projectId) { - $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $function['body']['$id'] . '/deployments/' . $deploymentId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]); - $this->assertEquals('ready', $deployment['body']['status']); - }); - - // Subscribe with query for execution with response (not null) - $client = $this->getWebsocket(['executions'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::isNotNull('response')->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Execute function - should receive event when execution completes with response - $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId - ], $this->getHeaders()), [ - 'async' => true - ]); - - // Wait for execution to complete - $event = json_decode($client->receive(), true); - if ($event['type'] === 'event' && isset($event['data']['payload']['response'])) { - $this->assertEquals('event', $event['type']); - $this->assertNotNull($event['data']['payload']['response']); - } - - $client->close(); - - // Cleanup - $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - 'x-appwrite-key' => $this->getProject()['apiKey'], - ], []); - - $targetTeamId = ID::unique(); - - // Subscribe with query for specific team ID - $client = $this->getWebsocket(['teams'], [ - 'origin' => 'http://localhost', - 'cookie' => 'a_session_' . $projectId . '=' . $session, - ], null, [ - Query::equal('$id', [$targetTeamId])->toString(), - ]); - - $response = json_decode($client->receive(), true); - $this->assertEquals('connected', $response['type']); - - // Create team with matching ID - should receive event - $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'teamId' => $targetTeamId, - 'name' => 'Query Test Team' - ]); - - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($targetTeamId, $event['data']['payload']['$id']); - - // Create team with different ID - should NOT receive event - $otherTeamId = ID::unique(); - $this->client->call(Client::METHOD_POST, '/teams', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $projectId, - ], $this->getHeaders()), [ - 'teamId' => $otherTeamId, - 'name' => 'Other Team' - ]); - - try { - $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); - } catch (TimeoutException $e) { - $this->assertTrue(true); - } - - $client->close(); - } - public function testMultipleQueriesWithOrLogic() { $user = $this->getUser(); From 3b4196735a594c92ed8a28f1fe1c7a4cb623dff4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 24 Dec 2025 21:02:03 +0530 Subject: [PATCH 6/9] refactor: simplify query handling in Realtime adapter and enhance error messaging for unsupported queries --- app/realtime.php | 2 +- src/Appwrite/Messaging/Adapter/Realtime.php | 21 ++++++--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 7774c2cc97..3a68005383 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -594,7 +594,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, 'type' => 'connected', 'data' => [ 'channels' => array_keys($channels), - 'queries' => array_keys($queries), + 'queries' => $queries, 'user' => $user ] ])); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 43068a9d46..2b877779c2 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -230,19 +230,6 @@ class Realtime extends MessagingAdapter return array_keys($receivers); } - public function filterEventData(array $documents, array $queries): array - { - if (empty($queries)) { - return $documents; - } - $filteredDocuments = []; - foreach ($documents as $document) { - $doc = new Document((array) $doc); - } - - return $filteredDocuments; - } - /** * Converts the channels from the Query Params into an array. * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. @@ -281,8 +268,12 @@ class Realtime extends MessagingAdapter $queries = Query::parseQueries($queries); foreach ($queries as $query) { if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { - // TODO: add better error message with which queries are allowed - throw new QueryException(Exception::REALTIME_POLICY_VIOLATION, 'Query not supported'); + $unsupportedMethod = $query->getMethod(); + $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES); + throw new QueryException( + Exception::REALTIME_POLICY_VIOLATION, + "Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}" + ); } } From da871635d9b975a5b7708f806b45fe6a1357d478 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 16:16:03 +0530 Subject: [PATCH 7/9] Fix namespace import for RuntimeQuery class and update test file accordingly --- src/Appwrite/Messaging/Adapter/Realtime.php | 2 +- .../Database/{Query => }/RuntimeQuery.php | 39 ++++++++----------- .../Database/Query/RuntimeQueryTest.php | 2 +- 3 files changed, 18 insertions(+), 25 deletions(-) rename src/Appwrite/Utopia/Database/{Query => }/RuntimeQuery.php (74%) diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 2b877779c2..1d7f3726cd 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -5,7 +5,7 @@ namespace Appwrite\Messaging\Adapter; use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; -use Appwrite\Utopia\Database\Query\RuntimeQuery; +use Appwrite\Utopia\Database\RuntimeQuery; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception\Query as QueryException; diff --git a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php b/src/Appwrite/Utopia/Database/RuntimeQuery.php similarity index 74% rename from src/Appwrite/Utopia/Database/Query/RuntimeQuery.php rename to src/Appwrite/Utopia/Database/RuntimeQuery.php index f97ba015ca..11257db21f 100644 --- a/src/Appwrite/Utopia/Database/Query/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/RuntimeQuery.php @@ -1,6 +1,6 @@ getValues(); // during 'and' and 'or' attribute will not be present - if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR])) { - switch ($method) { - case Query::TYPE_AND: - // All subqueries must evaluate to true - foreach ($query->getValues() as $subquery) { - if (!self::evaluateFilter($subquery, $payload)) { - return false; - } + 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; + } + 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; - } + 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; - - default: - throw new \InvalidArgumentException( - "Unsupported query method: {$method}" - ); - } + } + return false; } $hasAttribute = \array_key_exists($attribute, $payload); diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index 2156d862a5..35fbde04ce 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -2,7 +2,7 @@ namespace Tests\Unit\Utopia\Database\Query; -use Appwrite\Utopia\Database\Query\RuntimeQuery; +use Appwrite\Utopia\Database\RuntimeQuery; use PHPUnit\Framework\TestCase; use Utopia\Database\Query; From b9c7c172ad4727c8c59d932dfbb6413dd43d5d1b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 18:18:24 +0530 Subject: [PATCH 8/9] updated query conversion for nested query --- app/realtime.php | 7 +- src/Appwrite/Messaging/Adapter/Realtime.php | 17 ++-- .../RealtimeCustomClientQueryTest.php | 94 +++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/app/realtime.php b/app/realtime.php index 7c0c5dafa6..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; @@ -579,7 +580,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, $roles = $user->getRoles($authorization); $channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId()); - $queries = Realtime::convertQueries($request->getQuery('queries', [])); + try { + $queries = Realtime::convertQueries($request->getQuery('queries', [])); + } catch (QueryException $e) { + throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $e->getMessage()); + } /** * Channels Check diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 1d7f3726cd..9e03a7aaf7 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -2,7 +2,6 @@ namespace Appwrite\Messaging\Adapter; -use Appwrite\Extend\Exception; use Appwrite\Messaging\Adapter as MessagingAdapter; use Appwrite\PubSub\Adapter\Pool as PubSubPool; use Appwrite\Utopia\Database\RuntimeQuery; @@ -266,15 +265,21 @@ class Realtime extends MessagingAdapter public static function convertQueries(array $queries): array { $queries = Query::parseQueries($queries); - foreach ($queries as $query) { - if (!in_array($query->getMethod(), RuntimeQuery::ALLOWED_QUERIES)) { - $unsupportedMethod = $query->getMethod(); - $allowedMethods = implode(', ', RuntimeQuery::ALLOWED_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( - Exception::REALTIME_POLICY_VIOLATION, "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; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 0272450245..365d0d8f6b 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1425,4 +1425,98 @@ class RealtimeCustomClientQueryTest extends Scope $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') + ); + } } From b1fab79dc4d4ff12ef9ee046f1e83cf06bcdb864 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 16 Jan 2026 19:06:55 +0530 Subject: [PATCH 9/9] updated query logic in array to be of and format --- src/Appwrite/Utopia/Database/RuntimeQuery.php | 7 ++- .../RealtimeCustomClientQueryTest.php | 61 +++++++++++++------ .../Database/Query/RuntimeQueryTest.php | 17 +++++- 3 files changed, 62 insertions(+), 23 deletions(-) diff --git a/src/Appwrite/Utopia/Database/RuntimeQuery.php b/src/Appwrite/Utopia/Database/RuntimeQuery.php index 11257db21f..025d424768 100644 --- a/src/Appwrite/Utopia/Database/RuntimeQuery.php +++ b/src/Appwrite/Utopia/Database/RuntimeQuery.php @@ -33,12 +33,13 @@ class RuntimeQuery extends Query if (empty($queries)) { return $payload; } + // multiple queries follows and condition foreach ($queries as $query) { - if (self::evaluateFilter($query, $payload)) { - return $payload; + if (!self::evaluateFilter($query, $payload)) { + return []; }; } - return []; + return $payload; } private static function evaluateFilter(Query $query, array $payload): bool diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php index 365d0d8f6b..068736561e 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php @@ -1307,7 +1307,7 @@ class RealtimeCustomClientQueryTest extends Scope $client->close(); } - public function testMultipleQueriesWithOrLogic() + public function testMultipleQueriesWithAndLogic() { $user = $this->getUser(); $session = $user['session'] ?? ''; @@ -1350,27 +1350,26 @@ class RealtimeCustomClientQueryTest extends Scope sleep(2); - $docId1 = ID::unique(); - $docId2 = ID::unique(); + $targetDocId = ID::unique(); - // Subscribe with multiple queries (OR logic - any query matching returns event) + // 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', [$docId1])->toString(), - Query::equal('$id', [$docId2])->toString(), + Query::equal('$id', [$targetDocId])->toString(), + Query::equal('status', ['active'])->toString(), ]); $response = json_decode($client->receive(), true); $this->assertEquals('connected', $response['type']); - // Create document with first ID - should receive event + // 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' => $docId1, + 'documentId' => $targetDocId, 'data' => [ 'status' => 'active' ], @@ -1381,27 +1380,31 @@ class RealtimeCustomClientQueryTest extends Scope $event = json_decode($client->receive(), true); $this->assertEquals('event', $event['type']); - $this->assertEquals($docId1, $event['data']['payload']['$id']); + $this->assertEquals($targetDocId, $event['data']['payload']['$id']); + $this->assertEquals('active', $event['data']['payload']['status']); - // Create document with second ID - should receive event - $document2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + // 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' => $docId2, + 'documentId' => $targetDocId, 'data' => [ - 'status' => 'active' + 'status' => 'inactive' ], 'permissions' => [ Permission::read(Role::any()), ], ]); - $event = json_decode($client->receive(), true); - $this->assertEquals('event', $event['type']); - $this->assertEquals($docId2, $event['data']['payload']['$id']); + 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 different ID - should NOT receive event + // 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', @@ -1418,7 +1421,29 @@ class RealtimeCustomClientQueryTest extends Scope try { $client->receive(); - $this->fail('Expected TimeoutException - event should be filtered'); + $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); } diff --git a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php index 35fbde04ce..7df1ca80eb 100644 --- a/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php +++ b/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php @@ -487,6 +487,17 @@ class RuntimeQueryTest extends TestCase } // 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 = [ @@ -495,7 +506,8 @@ class RuntimeQueryTest extends TestCase ]; $payload = ['name' => 'John', 'age' => 30]; $result = RuntimeQuery::filter($queries, $payload); - $this->assertEquals($payload, $result); + // With AND logic, if first matches but second doesn't, should return empty + $this->assertEquals([], $result); } public function testMultipleQueriesSecondMatches(): void @@ -506,7 +518,8 @@ class RuntimeQueryTest extends TestCase ]; $payload = ['name' => 'John', 'age' => 30]; $result = RuntimeQuery::filter($queries, $payload); - $this->assertEquals($payload, $result); + // With AND logic, if second matches but first doesn't, should return empty + $this->assertEquals([], $result); } public function testMultipleQueriesNoneMatch(): void