mirror of
https://github.com/appwrite/appwrite
synced 2026-05-22 16:38:32 +00:00
Merge pull request #11005 from appwrite/dat-969
This commit is contained in:
commit
0723101397
7 changed files with 2331 additions and 10 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
121
src/Appwrite/Utopia/Database/RuntimeQuery.php
Normal file
121
src/Appwrite/Utopia/Database/RuntimeQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
1547
tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php
Normal file
1547
tests/e2e/Services/Realtime/RealtimeCustomClientQueryTest.php
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
602
tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
Normal file
602
tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue