Merge pull request #11005 from appwrite/dat-969

This commit is contained in:
Jake Barnby 2026-01-17 03:31:50 +13:00 committed by GitHub
commit 0723101397
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2331 additions and 10 deletions

View file

@ -29,6 +29,7 @@ use Utopia\Database\Adapter\Pool as DatabasePool;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
@ -473,9 +474,10 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats,
$roles = $user->getRoles($database->getAuthorization());
$channels = $realtime->connections[$connection]['channels'];
$queries = $realtime->connections[$connection]['queries'] ?? [];
$realtime->unsubscribe($connection);
$realtime->subscribe($projectId, $connection, $roles, $channels);
$realtime->subscribe($projectId, $connection, $roles, $channels, $queries);
}
}
@ -578,6 +580,11 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
$roles = $user->getRoles($authorization);
$channels = Realtime::convertChannels($request->getQuery('channels', []), $user->getId());
try {
$queries = Realtime::convertQueries($request->getQuery('queries', []));
} catch (QueryException $e) {
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, $e->getMessage());
}
/**
* Channels Check
@ -586,7 +593,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing channels');
}
$realtime->subscribe($project->getId(), $connection, $roles, $channels);
$realtime->subscribe($project->getId(), $connection, $roles, $channels, $queries);
$realtime->connections[$connection]['authorization'] = $authorization;
@ -596,6 +603,7 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server,
'type' => 'connected',
'data' => [
'channels' => array_keys($channels),
'queries' => $queries,
'user' => $user
]
]));
@ -730,7 +738,8 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
// Preserve authorization before subscribe overwrites the connection array
$authorization = $realtime->connections[$connection]['authorization'] ?? null;
$realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels);
$queries = $realtime->connections[$connection]['queries'];
$realtime->subscribe($realtime->connections[$connection]['projectId'], $connection, $roles, $channels, $queries);
// Restore authorization after subscribe
if ($authorization !== null) {

View file

@ -4,10 +4,13 @@ namespace Appwrite\Messaging\Adapter;
use Appwrite\Messaging\Adapter as MessagingAdapter;
use Appwrite\PubSub\Adapter\Pool as PubSubPool;
use Appwrite\Utopia\Database\RuntimeQuery;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Exception\Query as QueryException;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Query;
class Realtime extends MessagingAdapter
{
@ -51,9 +54,10 @@ class Realtime extends MessagingAdapter
* @param mixed $identifier
* @param array $roles
* @param array $channels
* @param array $queries
* @return void
*/
public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels): void
public function subscribe(string $projectId, mixed $identifier, array $roles, array $channels, array $queries = []): void
{
if (!isset($this->subscriptions[$projectId])) { // Init Project
$this->subscriptions[$projectId] = [];
@ -72,7 +76,8 @@ class Realtime extends MessagingAdapter
$this->connections[$identifier] = [
'projectId' => $projectId,
'roles' => $roles,
'channels' => $channels
'channels' => $channels,
'queries' => $queries
];
}
@ -206,7 +211,14 @@ class Realtime extends MessagingAdapter
/**
* To prevent duplicates, we save the connections as array keys.
*/
$receivers[$id] = 0;
$queries = $this->connections[$id]['queries'] ?? [];
$payload = $event['data']['payload'] ?? [];
if (
empty($queries) ||
!empty(RuntimeQuery::filter($queries, $payload))
) {
$receivers[$id] = 0;
}
}
break;
}
@ -245,6 +257,34 @@ class Realtime extends MessagingAdapter
return $channels;
}
/**
* Converts the queries from the Query Params into an array.
* @param array $queries
* @return array
*/
public static function convertQueries(array $queries): array
{
$queries = Query::parseQueries($queries);
$stack = $queries;
$allowedMethods = implode(', ', RuntimeQuery::ALLOWED_QUERIES);
while (!empty($stack)) {
/** `@var` Query $query */
$query = array_pop($stack);
$method = $query->getMethod();
if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) {
$unsupportedMethod = $method;
throw new QueryException(
"Query method '{$unsupportedMethod}' is not supported in Realtime queries. Allowed query methods are: {$allowedMethods}"
);
}
if (in_array($method, [Query::TYPE_AND, Query::TYPE_OR], true)) {
$stack = array_merge($stack, $query->getValues());
}
}
return $queries;
}
/**
* Create channels array based on the event name and payload.
*

View file

@ -0,0 +1,121 @@
<?php
namespace Appwrite\Utopia\Database;
use Utopia\Database\Query;
class RuntimeQuery extends Query
{
public const ALLOWED_QUERIES = [
// Equality & comparison
Query::TYPE_EQUAL,
Query::TYPE_NOT_EQUAL,
Query::TYPE_LESSER,
Query::TYPE_LESSER_EQUAL,
Query::TYPE_GREATER,
Query::TYPE_GREATER_EQUAL,
// Null checks
Query::TYPE_IS_NULL,
Query::TYPE_IS_NOT_NULL,
// Recursive checks
Query::TYPE_AND,
Query::TYPE_OR
];
/**
* @param array<Query> $queries
* @param array<string, mixed> $payload
*/
public static function filter(array $queries, array $payload): array
{
if (empty($queries)) {
return $payload;
}
// multiple queries follows and condition
foreach ($queries as $query) {
if (!self::evaluateFilter($query, $payload)) {
return [];
};
}
return $payload;
}
private static function evaluateFilter(Query $query, array $payload): bool
{
$attribute = $query->getAttribute();
$method = $query->getMethod();
$values = $query->getValues();
// during 'and' and 'or' attribute will not be present
switch ($method) {
case Query::TYPE_AND:
// All subqueries must evaluate to true
foreach ($query->getValues() as $subquery) {
if (!self::evaluateFilter($subquery, $payload)) {
return false;
}
}
return true;
case Query::TYPE_OR:
// At least one subquery must evaluate to true
foreach ($query->getValues() as $subquery) {
if (self::evaluateFilter($subquery, $payload)) {
return true;
}
}
return false;
}
$hasAttribute = \array_key_exists($attribute, $payload);
if (!$hasAttribute) {
return false;
}
// null can be a value as well
$payloadAttributeValue = $payload[$attribute];
switch ($method) {
case Query::TYPE_EQUAL:
return self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value);
case Query::TYPE_NOT_EQUAL:
return !self::anyMatch($values, fn ($value) => $payloadAttributeValue === $value);
case Query::TYPE_LESSER:
return self::anyMatch($values, fn ($value) => $payloadAttributeValue < $value);
case Query::TYPE_LESSER_EQUAL:
return self::anyMatch($values, fn ($value) => $payloadAttributeValue <= $value);
case Query::TYPE_GREATER:
return self::anyMatch($values, fn ($value) => $payloadAttributeValue > $value);
case Query::TYPE_GREATER_EQUAL:
return self::anyMatch($values, fn ($value) => $payloadAttributeValue >= $value);
// attribute must be present and should be explicitly null
case Query::TYPE_IS_NULL:
return $payloadAttributeValue === null;
case Query::TYPE_IS_NOT_NULL:
return $payloadAttributeValue !== null;
default:
throw new \InvalidArgumentException(
"Unsupported query method: {$method}"
);
}
}
private static function anyMatch(array $values, callable $fn): bool
{
foreach ($values as $value) {
if ($fn($value)) {
return true;
}
}
return false;
}
}

View file

@ -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(

File diff suppressed because it is too large Load diff

View file

@ -692,8 +692,8 @@ class RealtimeCustomClientTest extends Scope
$client = $this->getWebsocket(['documents', 'collections'], [
'origin' => 'http://localhost',
'cookie' => 'a_session_' . $projectId . '=' . $session
]);
'cookie' => 'a_session_' . $projectId . '=' . $session,
], null);
$response = json_decode($client->receive(), true);
@ -2962,7 +2962,7 @@ class RealtimeCustomClientTest extends Scope
sleep(1);
try {
$client->receive(1); // 1 second timeout
$client->receive();
$this->fail('Should not receive any event after rollback');
} catch (TimeoutException $e) {
// Expected - no event should be triggered

View file

@ -0,0 +1,602 @@
<?php
namespace Tests\Unit\Utopia\Database\Query;
use Appwrite\Utopia\Database\RuntimeQuery;
use PHPUnit\Framework\TestCase;
use Utopia\Database\Query;
class RuntimeQueryTest extends TestCase
{
public function setUp(): void
{
}
public function tearDown(): void
{
}
public function testFilterEmptyQueries(): void
{
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([], $payload);
$this->assertEquals($payload, $result);
}
public function testFilterWithNoMatchingQuery(): void
{
$queries = [Query::equal('name', ['Jane'])];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
$this->assertEquals([], $result);
}
public function testFilterWithMatchingQuery(): void
{
$queries = [Query::equal('name', ['John'])];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
$this->assertEquals($payload, $result);
}
// TYPE_EQUAL tests
public function testEqualMatch(): void
{
$query = Query::equal('name', ['John']);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualNoMatch(): void
{
$query = Query::equal('name', ['Jane']);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testEqualMultipleValuesMatch(): void
{
$query = Query::equal('status', ['active', 'pending', 'approved']);
$payload = ['status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualMultipleValuesNoMatch(): void
{
$query = Query::equal('status', ['active', 'pending', 'approved']);
$payload = ['status' => 'rejected'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testEqualNumericValues(): void
{
$query = Query::equal('age', [30, 25, 35]);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualBooleanValues(): void
{
$query = Query::equal('active', [true]);
$payload = ['active' => true];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualMissingAttribute(): void
{
$query = Query::equal('missing', ['value']);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
// TYPE_NOT_EQUAL tests
public function testNotEqualMatch(): void
{
$query = Query::notEqual('name', ['Jane']);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testNotEqualNoMatch(): void
{
$query = Query::notEqual('name', ['John']);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testNotEqualMultipleValues(): void
{
// generally from the client side they will pass query strings via the realtime
// and Query::parse will be done first and parse doesn't allow multiple notEqual values
$query = Query::notEqual('status', ['rejected', 'cancelled']);
$payload = ['status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
$query = Query::notEqual('status', ['active', 'pending']);
$payload = ['status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
// TYPE_LESSER tests
public function testLesserMatch(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 25];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserNoMatch(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 35];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testLesserEqualValue(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testLesserMultipleValues(): void
{
// Note: Query::lessThan only accepts single value, but RuntimeQuery's anyMatch supports arrays
// This test uses a single value as Query class requires
$query = Query::lessThan('age', 30);
$payload = ['age' => 25];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserStringComparison(): void
{
$query = Query::lessThan('name', 'M');
$payload = ['name' => 'A'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_LESSER_EQUAL tests
public function testLesserEqualMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 25];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserEqualExactMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserEqualNoMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 35];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testLesserEqualMultipleValues(): void
{
// Note: Query::lessThanEqual only accepts single value
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_GREATER tests
public function testGreaterMatch(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 35];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterNoMatch(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 25];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testGreaterEqualValue(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testGreaterMultipleValues(): void
{
// Note: Query::greaterThan only accepts single value
$query = Query::greaterThan('age', 20);
$payload = ['age' => 35];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_GREATER_EQUAL tests
public function testGreaterEqualMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 35];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterEqualExactMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterEqualNoMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 25];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testGreaterEqualMultipleValues(): void
{
// Note: Query::greaterThanEqual only accepts single value
$query = Query::greaterThanEqual('age', 20);
$payload = ['age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_IS_NULL tests
public function testIsNullMatch(): void
{
$query = Query::isNull('description');
$payload = ['description' => null];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testIsNullNoMatch(): void
{
$query = Query::isNull('description');
$payload = ['description' => 'Some text'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testIsNullMissingAttribute(): void
{
$query = Query::isNull('missing');
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
// TYPE_IS_NOT_NULL tests
public function testIsNotNullMatch(): void
{
$query = Query::isNotNull('description');
$payload = ['description' => 'Some text'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testIsNotNullNoMatch(): void
{
$query = Query::isNotNull('description');
$payload = ['description' => null];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testIsNotNullMissingAttribute(): void
{
$query = Query::isNotNull('missing');
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
// TYPE_AND tests
public function testAndAllMatch(): void
{
$query = Query::and([
Query::equal('name', ['John']),
Query::equal('age', [30])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testAndOneFails(): void
{
$query = Query::and([
Query::equal('name', ['John']),
Query::equal('age', [25])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testAndAllFail(): void
{
$query = Query::and([
Query::equal('name', ['Jane']),
Query::equal('age', [25])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testAndMultipleConditions(): void
{
$query = Query::and([
Query::equal('status', ['active']),
Query::greaterThan('age', 18),
Query::isNotNull('email')
]);
$payload = ['status' => 'active', 'age' => 25, 'email' => 'test@example.com'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testAndNestedAnd(): void
{
$query = Query::and([
Query::equal('name', ['John']),
Query::and([
Query::equal('age', [30]),
Query::equal('status', ['active'])
])
]);
$payload = ['name' => 'John', 'age' => 30, 'status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_OR tests
public function testOrOneMatch(): void
{
$query = Query::or([
Query::equal('name', ['John']),
Query::equal('name', ['Jane'])
]);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrAllMatch(): void
{
$query = Query::or([
Query::equal('status', ['active']),
Query::equal('status', ['pending'])
]);
$payload = ['status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrAllFail(): void
{
$query = Query::or([
Query::equal('name', ['Jane']),
Query::equal('age', [25])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testOrMultipleConditions(): void
{
$query = Query::or([
Query::equal('status', ['active']),
Query::equal('status', ['pending']),
Query::equal('status', ['approved'])
]);
$payload = ['status' => 'pending'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrNestedOr(): void
{
$query = Query::or([
Query::equal('name', ['John']),
Query::or([
Query::equal('name', ['Jane']),
Query::equal('name', ['Bob'])
])
]);
$payload = ['name' => 'Bob'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrWithDifferentAttributes(): void
{
$query = Query::or([
Query::equal('name', ['John']),
Query::equal('email', ['john@example.com'])
]);
$payload = ['name' => 'Jane', 'email' => 'john@example.com'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// Complex combinations
public function testAndOrCombination(): void
{
$query = Query::and([
Query::equal('type', ['user']),
Query::or([
Query::equal('status', ['active']),
Query::equal('status', ['pending'])
])
]);
$payload = ['type' => 'user', 'status' => 'active'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrAndCombination(): void
{
$query = Query::or([
Query::and([
Query::equal('name', ['John']),
Query::equal('age', [30])
]),
Query::and([
Query::equal('name', ['Jane']),
Query::equal('age', [25])
])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
// Edge cases
public function testMultipleQueriesAllMatch(): void
{
$queries = [
Query::equal('name', ['John']),
Query::equal('age', [30])
];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
$this->assertEquals($payload, $result);
}
public function testMultipleQueriesFirstMatches(): void
{
$queries = [
Query::equal('name', ['John']),
Query::equal('age', [25])
];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
// With AND logic, if first matches but second doesn't, should return empty
$this->assertEquals([], $result);
}
public function testMultipleQueriesSecondMatches(): void
{
$queries = [
Query::equal('name', ['Jane']),
Query::equal('age', [30])
];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
// With AND logic, if second matches but first doesn't, should return empty
$this->assertEquals([], $result);
}
public function testMultipleQueriesNoneMatch(): void
{
$queries = [
Query::equal('name', ['Jane']),
Query::equal('age', [25])
];
$payload = ['name' => 'John', 'age' => 30];
$result = RuntimeQuery::filter($queries, $payload);
$this->assertEquals([], $result);
}
public function testEmptyPayload(): void
{
$query = Query::equal('name', ['John']);
$payload = [];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals([], $result);
}
public function testEmptyAndQuery(): void
{
$query = Query::and([]);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
// Empty AND should return true (all conditions pass vacuously)
$this->assertEquals($payload, $result);
}
public function testEmptyOrQuery(): void
{
$query = Query::or([]);
$payload = ['name' => 'John'];
$result = RuntimeQuery::filter([$query], $payload);
// Empty OR should return false (no conditions match)
$this->assertEquals([], $result);
}
// Type-specific edge cases
public function testEqualWithZero(): void
{
$query = Query::equal('count', [0]);
$payload = ['count' => 0];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualWithEmptyString(): void
{
$query = Query::equal('name', ['']);
$payload = ['name' => ''];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualWithFalse(): void
{
$query = Query::equal('active', [false]);
$payload = ['active' => false];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testComparisonWithFloat(): void
{
$query = Query::greaterThan('score', 8.5);
$payload = ['score' => 9.2];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testComparisonWithStringNumbers(): void
{
$query = Query::lessThan('version', '10');
$payload = ['version' => '9'];
$result = RuntimeQuery::filter([$query], $payload);
$this->assertEquals($payload, $result);
}
}