Files
appwrite/tests/unit/Utopia/Database/Query/RuntimeQueryTest.php
Chirag Aggarwal d2230f8fe7 chore: bump PHPStan to level 4 and fix all new errors
Raises `phpstan.neon` level from 3 to 4 and fixes the 549 new errors
that level 4 surfaces across 157 files. Fixes are root-cause — no
`@phpstan-ignore`, no `@var` casts, no baseline entries, no widened
types. A handful of latent bugs were fixed along the way:

- `app/controllers/general.php`: path-traversal guard was negating
  `\substr(...)` before the strict comparison (`!\substr(...) === $base`
  was always `false === $base`). Rewritten as `\substr(...) !== $base`.
- `src/Appwrite/Platform/Modules/Databases/Http/Databases/Logs/XList.php`
  and `.../TablesDB/Logs/XList.php`: were importing the raw Matomo
  `DeviceDetector` (whose `getDevice()` returns `?int`) but treating the
  result as an array with `deviceName/deviceBrand/deviceModel` keys.
  Swapped to `Appwrite\Detector\Detector`, matching the wrapper already
  used a few lines below for `$os`/`$client`.
- `src/Appwrite/Platform/Modules/Functions/Workers/Builds.php`: a match
  key was checking `$resourceKey === 'functions'` when `$resourceKey`
  is `'functionId'|'siteId'` — always false. Switched to the intended
  `$resource->getCollection() === 'functions'` check.
- `src/Appwrite/OpenSSL/OpenSSL.php`: `encrypt()` return type tightened
  to `string|false` to match `openssl_encrypt`; this lets callers'
  `=== false` error handling remain meaningful.
- `app/controllers/api/messaging.php`: removed a dead
  `array_key_exists('from', [])` branch in the Msg91 provider (empty
  array literal; branch was unreachable).

Large cleanup categories across the 549 fixes:
- Removed redundant `?? default` on array offsets and expressions that
  PHPStan now knows are non-nullable.
- Removed unreachable statements (mostly `return;` after `throw` or
  `markTestSkipped()`).
- Removed redundant `is_array`/`is_string`/`is_bool`/`instanceof` checks
  on already-narrowed types.
- Added `default =>` arms (or throwing arms) to non-exhaustive matches
  on `string`/`mixed` input.
- Removed dead `$document === false` branches where method return types
  were tightened to non-nullable `Document`.
- Removed unused properties (`$version` on Etsy/Zoom OAuth2, `$paths` on
  Installer State, `$source` on MigrationsWorker, `$account2` on two
  GraphQL auth tests), unused traits (`ApiVectorsDB`, `DatabaseFixture`),
  and an unused `cleanupStaleExecutions` task method.
- Replaced `assertTrue(true)` and redundant `assertIsArray`/`assertIsString`/
  `assertNotNull` assertions with `addToAssertionCount(1)` or
  `assertNotEmpty` where the runtime type was already known.
2026-04-19 17:31:20 +05:30

730 lines
24 KiB
PHP

<?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
{
}
/**
* Helper to compile and filter queries in one step for tests.
*/
private function compileAndFilter(array $queries, array $payload): ?array
{
$compiled = RuntimeQuery::compile($queries);
return RuntimeQuery::filter($compiled, $payload);
}
public function testFilterEmptyQueries(): void
{
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter([], $payload);
$this->assertEquals($payload, $result);
}
public function testFilterWithNoMatchingQuery(): void
{
$queries = [Query::equal('name', ['Jane'])];
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter($queries, $payload);
$this->assertNull($result);
}
public function testFilterWithMatchingQuery(): void
{
$queries = [Query::equal('name', ['John'])];
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter($queries, $payload);
$this->assertEquals($payload, $result);
}
// TYPE_EQUAL tests
public function testEqualMatch(): void
{
$query = Query::equal('name', ['John']);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualNoMatch(): void
{
$query = Query::equal('name', ['Jane']);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testEqualMultipleValuesMatch(): void
{
$query = Query::equal('status', ['active', 'pending', 'approved']);
$payload = ['status' => 'active'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualMultipleValuesNoMatch(): void
{
$query = Query::equal('status', ['active', 'pending', 'approved']);
$payload = ['status' => 'rejected'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testEqualNumericValues(): void
{
$query = Query::equal('age', [30, 25, 35]);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualBooleanValues(): void
{
$query = Query::equal('active', [true]);
$payload = ['active' => true];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualMissingAttribute(): void
{
$query = Query::equal('missing', ['value']);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
// TYPE_NOT_EQUAL tests
public function testNotEqualMatch(): void
{
$query = Query::notEqual('name', ['Jane']);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testNotEqualNoMatch(): void
{
$query = Query::notEqual('name', ['John']);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($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 = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
$query = Query::notEqual('status', ['active', 'pending']);
$payload = ['status' => 'active'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
// TYPE_LESSER tests
public function testLesserMatch(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 25];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserNoMatch(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 35];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testLesserEqualValue(): void
{
$query = Query::lessThan('age', 30);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($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 = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserStringComparison(): void
{
$query = Query::lessThan('name', 'M');
$payload = ['name' => 'A'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_LESSER_EQUAL tests
public function testLesserEqualMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 25];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserEqualExactMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testLesserEqualNoMatch(): void
{
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 35];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testLesserEqualMultipleValues(): void
{
// Note: Query::lessThanEqual only accepts single value
$query = Query::lessThanEqual('age', 30);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_GREATER tests
public function testGreaterMatch(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 35];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterNoMatch(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 25];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testGreaterEqualValue(): void
{
$query = Query::greaterThan('age', 30);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testGreaterMultipleValues(): void
{
// Note: Query::greaterThan only accepts single value
$query = Query::greaterThan('age', 20);
$payload = ['age' => 35];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_GREATER_EQUAL tests
public function testGreaterEqualMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 35];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterEqualExactMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testGreaterEqualNoMatch(): void
{
$query = Query::greaterThanEqual('age', 30);
$payload = ['age' => 25];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testGreaterEqualMultipleValues(): void
{
// Note: Query::greaterThanEqual only accepts single value
$query = Query::greaterThanEqual('age', 20);
$payload = ['age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_IS_NULL tests
public function testIsNullMatch(): void
{
$query = Query::isNull('description');
$payload = ['description' => null];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testIsNullNoMatch(): void
{
$query = Query::isNull('description');
$payload = ['description' => 'Some text'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testIsNullMissingAttribute(): void
{
$query = Query::isNull('missing');
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
// TYPE_IS_NOT_NULL tests
public function testIsNotNullMatch(): void
{
$query = Query::isNotNull('description');
$payload = ['description' => 'Some text'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testIsNotNullNoMatch(): void
{
$query = Query::isNotNull('description');
$payload = ['description' => null];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testIsNotNullMissingAttribute(): void
{
$query = Query::isNotNull('missing');
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testAndAllFail(): void
{
$query = Query::and([
Query::equal('name', ['Jane']),
Query::equal('age', [25])
]);
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testOrMultipleConditions(): void
{
$query = Query::or([
Query::equal('status', ['active']),
Query::equal('status', ['pending']),
Query::equal('status', ['approved'])
]);
$payload = ['status' => 'pending'];
$result = $this->compileAndFilter([$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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrWithMissingAttributeInOneBranch(): void
{
// OR should match when one branch's attribute is missing but another branch matches
$query = Query::or([
Query::equal('name', ['John']),
Query::equal('email', ['john@example.com'])
]);
// Payload only has email, not name - should still match via email branch
$payload = ['email' => 'john@example.com'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testOrWithMissingAttributeNoMatch(): void
{
// OR should not match when the only matching branch has missing attribute
$query = Query::or([
Query::equal('name', ['John']),
Query::equal('email', ['john@example.com'])
]);
// Payload only has name but it doesn't match - should not match
$payload = ['name' => 'Jane'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($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 = $this->compileAndFilter([$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 = $this->compileAndFilter([$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 = $this->compileAndFilter($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 = $this->compileAndFilter($queries, $payload);
// With AND logic, if first matches but second doesn't, should return empty
$this->assertNull($result);
}
public function testMultipleQueriesSecondMatches(): void
{
$queries = [
Query::equal('name', ['Jane']),
Query::equal('age', [30])
];
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter($queries, $payload);
// With AND logic, if second matches but first doesn't, should return empty
$this->assertNull($result);
}
public function testMultipleQueriesNoneMatch(): void
{
$queries = [
Query::equal('name', ['Jane']),
Query::equal('age', [25])
];
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter($queries, $payload);
$this->assertNull($result);
}
public function testEmptyPayload(): void
{
$query = Query::equal('name', ['John']);
$payload = [];
$result = $this->compileAndFilter([$query], $payload);
$this->assertNull($result);
}
public function testEmptyAndQuery(): void
{
$query = Query::and([]);
$payload = ['name' => 'John'];
$result = $this->compileAndFilter([$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 = $this->compileAndFilter([$query], $payload);
// Empty OR should return false (no conditions match)
$this->assertNull($result);
}
// Type-specific edge cases
public function testEqualWithZero(): void
{
$query = Query::equal('count', [0]);
$payload = ['count' => 0];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualWithEmptyString(): void
{
$query = Query::equal('name', ['']);
$payload = ['name' => ''];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testEqualWithFalse(): void
{
$query = Query::equal('active', [false]);
$payload = ['active' => false];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testComparisonWithFloat(): void
{
$query = Query::greaterThan('score', 8.5);
$payload = ['score' => 9.2];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testComparisonWithStringNumbers(): void
{
$query = Query::lessThan('version', '10');
$payload = ['version' => '9'];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
// TYPE_SELECT tests - select("*") means "listen to all events"
public function testSelectAllIsAllowed(): void
{
$query = Query::select(['*']);
$this->assertTrue(RuntimeQuery::isSelectAll($query));
}
public function testSelectSpecificFieldsNotAllowed(): void
{
$query = Query::select(['name', 'age']);
$this->assertFalse(RuntimeQuery::isSelectAll($query));
}
public function testSelectSingleFieldNotAllowed(): void
{
$query = Query::select(['name']);
$this->assertFalse(RuntimeQuery::isSelectAll($query));
}
public function testValidateSelectQueryWithWildcard(): void
{
$query = Query::select(['*']);
// Should not throw
RuntimeQuery::validateSelectQuery($query);
$this->addToAssertionCount(1);
}
public function testValidateSelectQueryWithSpecificFields(): void
{
$query = Query::select(['name', 'age']);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only select("*") is allowed in Realtime queries');
RuntimeQuery::validateSelectQuery($query);
}
public function testValidateSelectQueryWithSingleField(): void
{
$query = Query::select(['name']);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Only select("*") is allowed in Realtime queries');
RuntimeQuery::validateSelectQuery($query);
}
public function testSelectInAllowedQueries(): void
{
$this->assertContains(Query::TYPE_SELECT, RuntimeQuery::ALLOWED_QUERIES);
}
public function testIsSelectAllWithNonSelectQuery(): void
{
$query = Query::equal('name', ['John']);
$this->assertFalse(RuntimeQuery::isSelectAll($query));
}
public function testValidateSelectQueryWithNonSelectQuery(): void
{
$query = Query::equal('name', ['John']);
// Should not throw for non-select queries
RuntimeQuery::validateSelectQuery($query);
$this->addToAssertionCount(1);
}
// Filter tests with select("*")
public function testFilterWithSelectAllReturnsPayload(): void
{
$query = Query::select(['*']);
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
public function testFilterWithSelectAllAndOtherQueriesReturnsPayload(): void
{
// If select("*") is present, it should return payload regardless of other queries
$queries = [
Query::select(['*']),
Query::equal('name', ['Jane']), // This would normally fail
];
$payload = ['name' => 'John', 'age' => 30];
$result = $this->compileAndFilter($queries, $payload);
// select("*") takes precedence - returns payload
$this->assertEquals($payload, $result);
}
public function testFilterWithSelectAllOnEmptyPayload(): void
{
$query = Query::select(['*']);
$payload = [];
$result = $this->compileAndFilter([$query], $payload);
$this->assertEquals($payload, $result);
}
}