Compare commits

...
Author SHA1 Message Date
Jake BarnbyandClaude Opus 4.5 f79e846cb2 Fix GraphQL hanging by running both promise queues
The graphql-php library uses its own SyncPromise queue for Deferred
resolution, which is separate from our SwoolePromise queue. The wait()
method now runs both queues to ensure all deferred tasks are processed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 20:20:11 +13:00
Jake BarnbyandClaude Opus 4.5 9ed02eb194 Fix GraphQL promise resolution hanging by using wait()
Replace WaitGroup-based promise waiting with adapter's wait() method
which properly runs the deferred task queue. The previous implementation
would hang indefinitely because the queue was never processed, causing
the WaitGroup callback to never be called.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 18:20:37 +13:00
Jake BarnbyandClaude Opus 4.5 a39627e555 Use queue-based deferred execution matching SyncPromise
- Callbacks are now enqueued to task queue instead of executed immediately
- wait() runs the queue until promise settles (like SyncPromiseAdapter)
- This properly supports graphql-php's deferred execution model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:50:44 +13:00
Jake BarnbyandClaude Opus 4.5 5138ebec7b Use GQLPromise::then() in all() to match SyncPromiseAdapter
Call through GQLPromise::then() instead of directly on adopted promise.
This matches how SyncPromiseAdapter handles promise chaining.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:32:09 +13:00
Jake BarnbyandClaude Opus 4.5 ca30269486 Execute promise callbacks synchronously for WaitGroup compat
The GraphQL controller uses Swoole WaitGroup to wait for promise callbacks.
Queue-based deferred execution causes deadlock because runQueue() is never
called. This change executes callbacks immediately when then() is called
on settled promises, allowing the WaitGroup pattern to work correctly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 00:17:23 +13:00
Jake BarnbyandClaude Opus 4.5 6ae070c3f1 Fix create() to match SyncPromiseAdapter pattern
Call resolver synchronously instead of enqueuing it.
This matches how graphql-php's SyncPromiseAdapter works.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:53:29 +13:00
Jake BarnbyandClaude Opus 4.5 233c4761d6 Implement task queue and wait() for graphql-php compatibility
- Add task queue mechanism to SwoolePromise matching SyncPromise behavior
- Callbacks are enqueued instead of executed immediately
- Add wait() method to Swoole adapter to process queue until promise settles
- This fixes hanging tests by properly executing deferred promise callbacks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:35:58 +13:00
Jake BarnbyandClaude Opus 4.5 d7174d0de3 Fix PSR-12 style for arrow functions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:05:13 +13:00
Jake BarnbyandClaude Opus 4.5 66c83162d3 Rewrite SwoolePromise to be fully synchronous
Remove all coroutine-based execution. The executor now runs
synchronously in the constructor, matching graphql-php's SyncPromise
behavior. This ensures all promise operations complete immediately
without scheduling issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 23:02:47 +13:00
Jake BarnbyandClaude Opus 4.5 8b297f5a89 Simplify all() to use SwoolePromise.then() directly
Call then() on the adopted SwoolePromise directly instead of going
through the GQLPromise wrapper. This ensures callbacks are properly
registered and fired via SwoolePromise's processWaiting().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:48:30 +13:00
Jake BarnbyandClaude Opus 4.5 4604c77939 Fix createFulfilled/createRejected to resolve immediately
These methods should return already-resolved promises, not promises
that will resolve in a future coroutine. This fixes the issue where
graphql-php's synchronous execution returns "fulfilled" promises that
are actually still pending.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:33:32 +13:00
Jake BarnbyandClaude Opus 4.5 ef46367fa3 Use Channel-based polling in all() implementation
Instead of relying on then() callbacks which may not fire correctly,
poll the adopted promise state directly using Swoole Coroutine::sleep
for proper yielding. Each promise is waited on in its own coroutine.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:21:07 +13:00
Jake BarnbyandClaude Opus 4.5 3078e6bff5 Run promise callbacks synchronously instead of in new coroutines
This avoids scheduling issues where spawned coroutines don't get a
chance to run before the main coroutine continues waiting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:06:34 +13:00
Jake BarnbyandClaude Opus 4.5 80643faacc Rewrite SwoolePromise with callback-based resolution
Replace busy-waiting in then() with proper callback queuing:
- Store waiting callbacks in array when promise is pending
- When promise resolves/rejects, process all waiting callbacks
- Run callbacks in coroutines for proper async execution
- This eliminates deadlocks from busy-wait polling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:35:49 +13:00
Jake BarnbyandClaude Opus 4.5 cd75f17815 Add public resolve/reject to Promise and fix all() implementation
- Add public resolve() and reject() methods to Promise class
- Create combined promise without executor (no coroutine spawned)
- Use direct resolve/reject calls instead of captured callbacks
- This ensures the promise can be settled regardless of coroutine timing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:56:28 +13:00
Jake BarnbyandClaude Opus 4.5 7b9970af9c Rewrite all() to match SyncPromiseAdapter pattern
Use callback-based approach similar to graphql-php's SyncPromiseAdapter:
- Create a combined promise and capture resolve/reject callbacks
- Register then() callbacks on each input promise
- Resolve when all callbacks have fired

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:35:33 +13:00
Jake BarnbyandClaude Opus 4.5 2544baa669 Fix batch promises with coroutine-based synchronization
- Use Swoole Coroutine::sleep for proper coroutine yielding instead of
  callback-based completion tracking
- Spawn a coroutine for each promise to wait for its completion
- Use Channel for synchronization between coroutines
- Add public accessors to Promise class for state checking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:10:07 +13:00
Jake BarnbyandClaude Opus 4.5 4d6a3d36f7 Fix batch promise handling with callback-based completion tracking
Use callback-based counting instead of channel synchronization to track
promise completion. Work directly with GQLPromise objects and use their
then() method which properly chains through the adapter to the underlying
SwoolePromise.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:46:36 +13:00
Jake BarnbyandClaude Opus 4.5 ab936a7ce4 Simplify Swoole adapter all() by delegating to SwoolePromise::all
Instead of reimplementing the promise aggregation logic, extract adopted
promises from GQLPromise wrappers and delegate to SwoolePromise::all()
which already handles the coroutine synchronization properly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:17:32 +13:00
Jake BarnbyandClaude Opus 4.5 b671bfb1c7 Fix Swoole adapter all() to work directly with GQLPromise objects
Instead of extracting adopted promises and delegating to SwoolePromise::all(),
implement the batch handling logic directly to properly work with GQLPromise
wrappers. Uses Swoole Channel for synchronization.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:55:20 +13:00
Jake BarnbyandClaude Opus 4.5 a70d67810e Fix Promise::all() to handle mixed values and promises
The graphql-php executor passes both promises and plain values to
the PromiseAdapter::all() method. Updated SwoolePromise::all() to
properly handle plain values by storing them directly without
attempting to call ->then() on them.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 18:36:40 +13:00
Jake Barnby 762be367e1 Fix collision 2026-01-21 18:09:03 +13:00
Jake Barnby f3b2562add Flag dirty across workers 2026-01-21 17:22:33 +13:00
Jake Barnby 0d26a2a2dd Merge remote-tracking branch 'origin/1.8.x' into feat-dynamic-graphql
# Conflicts:
#	app/init/resources.php
#	composer.lock
2026-01-21 14:02:19 +13:00
Jake Barnby 124ee0df8f Remove redundant type locking 2026-01-21 13:59:52 +13:00
Jake Barnby 802379366a Fix project/collection schema sanitization 2026-01-21 13:59:40 +13:00
Jake Barnby 9b8dcfda07 Fix custom scalar 2026-01-21 13:59:24 +13:00
Jake Barnby e9698c96f0 Allow dynamic types 2026-01-21 01:32:34 +13:00
Jake Barnby 15de315cae Update graphql lib 2026-01-06 16:29:42 +13:00
31 changed files with 4210 additions and 463 deletions
+1
View File
@@ -110,6 +110,7 @@ _APP_GRAPHQL_INTROSPECTION=enabled
_APP_GRAPHQL_MAX_BATCH_SIZE=10
_APP_GRAPHQL_MAX_COMPLEXITY=250
_APP_GRAPHQL_MAX_DEPTH=4
_APP_GRAPHQL_SCHEMA_CACHE_MB=50
_APP_DOCKER_HUB_USERNAME=
_APP_DOCKER_HUB_PASSWORD=
_APP_VCS_GITHUB_APP_NAME=
+9
View File
@@ -1321,6 +1321,15 @@ return [
'question' => '',
'filter' => ''
],
[
'name' => '_APP_GRAPHQL_SCHEMA_CACHE_MB',
'description' => 'Maximum memory in megabytes for the GraphQL schema LRU cache. Each project with database collections generates its own schema. Schemas are evicted when the cache exceeds this limit. Default is 50 MB.',
'introduction' => '1.8.0',
'default' => '50',
'required' => false,
'question' => '',
'filter' => 'integer'
],
],
],
[
+9 -18
View File
@@ -2,8 +2,8 @@
use Appwrite\Extend\Exception;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\GraphQL\Cache as GraphQLCache;
use Appwrite\GraphQL\Promises\Adapter;
use Appwrite\GraphQL\Schema;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\MethodType;
@@ -17,7 +17,6 @@ use GraphQL\Type\Schema as GQLSchema;
use GraphQL\Validator\Rules\DisableIntrospection;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\Rules\QueryDepth;
use Swoole\Coroutine\WaitGroup;
use Utopia\App;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
@@ -248,21 +247,12 @@ function execute(
);
}
$output = [];
$wg = new WaitGroup();
$wg->add();
$promiseAdapter->all($promises)->then(
function (array $results) use (&$output, &$wg, $flags) {
try {
$output = processResult($results, $flags);
} finally {
$wg->done();
}
}
);
$wg->wait();
$allPromise = $promiseAdapter->all($promises);
return $output;
// Use the adapter's wait() to run the queue and resolve promises
$results = $promiseAdapter->wait($allPromise);
return processResult($results, $flags);
}
/**
@@ -336,6 +326,7 @@ function processResult($result, $debugFlags): array
App::shutdown()
->groups(['schema'])
->inject('project')
->action(function (Document $project) {
Schema::setDirty($project->getId());
->inject('graphqlCache')
->action(function (Document $project, GraphQLCache $graphqlCache) {
$graphqlCache->setDirty($project->getId());
});
+19
View File
@@ -1,6 +1,7 @@
<?php
use Appwrite\Extend\Exception;
use Appwrite\GraphQL\Cache as GraphQLCache;
use Appwrite\GraphQL\Promises\Adapter\Swoole;
use Appwrite\Hooks\Hooks;
use Appwrite\PubSub\Adapter\Redis as PubSub;
@@ -8,6 +9,7 @@ use Appwrite\URL\URL as AppwriteURL;
use MaxMind\Db\Reader;
use PHPMailer\PHPMailer\PHPMailer;
use Swoole\Database\PDOProxy;
use Swoole\Table;
use Utopia\App;
use Utopia\Cache\Adapter\Redis as RedisCache;
use Utopia\CLI\Console;
@@ -391,6 +393,23 @@ $register->set('passwordsDictionary', function () {
$register->set('promiseAdapter', function () {
return new Swoole();
});
$graphqlFlags = new Table(100_000); // 100k projects max
$graphqlFlags->column('timestamp', Table::TYPE_INT, 8);
$graphqlFlags->create();
$register->set('graphqlFlags', fn () => $graphqlFlags);
$register->set('graphqlCache', function () use ($graphqlFlags) {
$maxMB = (int) System::getEnv('_APP_GRAPHQL_SCHEMA_CACHE_MB', 50);
return new GraphQLCache($maxMB, $graphqlFlags);
});
$register->set('graphqlAPISchema', function () {
// Container for API queries/mutations lazy init
return new \stdClass();
});
$register->set('hooks', function () {
return new Hooks();
});
+160 -58
View File
@@ -1064,7 +1064,17 @@ App::setResource('promiseAdapter', function ($register) {
return $register->get('promiseAdapter');
}, ['register']);
App::setResource('schema', function ($utopia, $dbForProject, $authorization) {
App::setResource('graphqlCache', function ($register) {
return $register->get('graphqlCache');
}, ['register']);
App::setResource('graphqlAPISchema', function ($register) {
return $register->get('graphqlAPISchema');
}, ['register']);
App::setResource('schema', function ($utopia, $dbForProject, $project, $graphqlCache, $authorization) {
$projectId = $project->getId();
$complexity = function (int $complexity, array $args) {
$queries = Query::parseQueries($args['queries'] ?? []);
@@ -1074,82 +1084,174 @@ App::setResource('schema', function ($utopia, $dbForProject, $authorization) {
return $complexity * $limit;
};
$attributes = function (int $limit, int $offset) use ($dbForProject, $authorization) {
$attrs = $authorization->skip(fn () => $dbForProject->find('attributes', [
Query::limit($limit),
Query::offset($offset),
]));
$types = null;
return \array_map(function ($attr) {
return $attr->getArrayCopy();
}, $attrs);
$attributes = function (int $limit, ?Document $last) use ($dbForProject, $projectId, $authorization, &$types) {
// Console project doesn't have user-created databases/collections
if ($projectId === 'console') {
return [];
}
// Lazy load database types on first pagination call
if ($types === null) {
$types = [];
$databases = $authorization->skip(fn () => $dbForProject->find('databases', [
Query::limit(APP_LIMIT_COUNT),
]));
foreach ($databases as $db) {
$dbType = $db->getAttribute('type', 'legacy');
if (!\in_array($dbType, ['legacy', 'tablesdb'])) {
Console::warning("Unknown database type '{$dbType}' for database {$db->getId()}, using 'legacy'");
$dbType = 'legacy';
}
$types[$db->getId()] = $dbType;
}
}
$queries = [
Query::equal('status', ['available']),
Query::limit($limit),
];
if ($last !== null) {
$queries[] = Query::cursorAfter($last);
}
$attributes = $authorization->skip(fn () => $dbForProject->find('attributes', $queries));
foreach ($attributes as $attribute) {
$dbId = $attribute->getAttribute('databaseId');
$attribute->setAttribute('databaseType', $types[$dbId] ?? 'legacy');
}
return $attributes;
};
$urls = [
'list' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents";
},
'create' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents";
},
'read' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
},
'update' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
},
'delete' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['documentId']}";
},
'legacy' => [
'list' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents";
},
'create' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents";
},
'read' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}";
},
'update' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}";
},
'delete' => function (string $databaseId, string $collectionId, array $args) {
return "/v1/databases/$databaseId/collections/$collectionId/documents/{$args['id']}";
},
],
'tablesdb' => [
'list' => function (string $databaseId, string $tableId, array $args) {
return "/v1/tablesdb/$databaseId/tables/$tableId/rows";
},
'create' => function (string $databaseId, string $tableId, array $args) {
return "/v1/tablesdb/$databaseId/tables/$tableId/rows";
},
'read' => function (string $databaseId, string $tableId, array $args) {
return "/v1/tablesdb/$databaseId/tables/$tableId/rows/{$args['id']}";
},
'update' => function (string $databaseId, string $tableId, array $args) {
return "/v1/tablesdb/$databaseId/tables/$tableId/rows/{$args['id']}";
},
'delete' => function (string $databaseId, string $tableId, array $args) {
return "/v1/tablesdb/$databaseId/tables/$tableId/rows/{$args['id']}";
},
],
];
// NOTE: `params` and `urls` are not used internally in the `Schema::build` function below!
$params = [
'list' => function (string $databaseId, string $collectionId, array $args) {
return ['queries' => $args['queries']];
},
'create' => function (string $databaseId, string $collectionId, array $args) {
$id = $args['id'] ?? 'unique()';
$permissions = $args['permissions'] ?? null;
'legacy' => [
'list' => function (string $databaseId, string $collectionId, array $args) {
return ['queries' => $args['queries'] ?? []];
},
'create' => function (string $databaseId, string $collectionId, array $args) {
$id = $args['id'] ?? 'unique()';
$permissions = $args['permissions'] ?? null;
unset($args['id']);
unset($args['permissions']);
unset($args['id']);
unset($args['permissions']);
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'documentId' => $id,
'collectionId' => $collectionId,
'data' => $args,
'permissions' => $permissions,
];
},
'update' => function (string $databaseId, string $collectionId, array $args) {
$documentId = $args['id'];
$permissions = $args['permissions'] ?? null;
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'documentId' => $id,
'collectionId' => $collectionId,
'data' => $args,
'permissions' => $permissions,
];
},
'update' => function (string $databaseId, string $collectionId, array $args) {
$documentId = $args['id'];
$permissions = $args['permissions'] ?? null;
unset($args['id']);
unset($args['permissions']);
unset($args['id']);
unset($args['permissions']);
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'documentId' => $documentId,
'data' => $args,
'permissions' => $permissions,
];
},
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'documentId' => $documentId,
'data' => $args,
'permissions' => $permissions,
];
},
],
'tablesdb' => [
'list' => function (string $databaseId, string $tableId, array $args) {
return ['queries' => $args['queries'] ?? []];
},
'create' => function (string $databaseId, string $tableId, array $args) {
$id = $args['id'] ?? 'unique()';
$permissions = $args['permissions'] ?? null;
unset($args['id']);
unset($args['permissions']);
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'rowId' => $id,
'tableId' => $tableId,
'data' => $args,
'permissions' => $permissions,
];
},
'update' => function (string $databaseId, string $tableId, array $args) {
$rowId = $args['id'];
$permissions = $args['permissions'] ?? null;
unset($args['id']);
unset($args['permissions']);
// Order must be the same as the route params
return [
'databaseId' => $databaseId,
'tableId' => $tableId,
'rowId' => $rowId,
'data' => $args,
'permissions' => $permissions,
];
},
],
];
return Schema::build(
$schema = new Schema($projectId);
return $schema->build(
$utopia,
$graphqlCache,
$complexity,
$attributes,
$urls,
$params,
);
}, ['utopia', 'dbForProject', 'authorization']);
}, ['utopia', 'dbForProject', 'project', 'graphqlCache', 'authorization']);
App::setResource('gitHub', function (Cache $cache) {
return new VcsGitHub($cache);
+1 -1
View File
@@ -83,7 +83,7 @@
"chillerlan/php-qrcode": "4.4.*",
"adhocore/jwt": "1.1.*",
"spomky-labs/otphp": "10.0.*",
"webonyx/graphql-php": "14.11.*",
"webonyx/graphql-php": "15.24.*",
"league/csv": "9.24.*",
"enshrined/svg-sanitize": "0.22.*"
},
Generated
+30 -21
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "33da844fdf5648d1d1a027dfb6ae42bc",
"content-hash": "7d3c04ff783454cb9ae8eff5f3e7088e",
"packages": [
{
"name": "adhocore/jwt",
@@ -5415,38 +5415,47 @@
},
{
"name": "webonyx/graphql-php",
"version": "v14.11.10",
"version": "v15.24.0",
"source": {
"type": "git",
"url": "https://github.com/webonyx/graphql-php.git",
"reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19"
"reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19",
"reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19",
"url": "https://api.github.com/repos/webonyx/graphql-php/zipball/030a04d22d52d7fc07049d0e3b683d2b40f90457",
"reference": "030a04d22d52d7fc07049d0e3b683d2b40f90457",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"php": "^7.1 || ^8"
"php": "^7.4 || ^8"
},
"require-dev": {
"amphp/amp": "^2.3",
"doctrine/coding-standard": "^6.0",
"nyholm/psr7": "^1.2",
"amphp/amp": "^2.6",
"amphp/http-server": "^2.1",
"dms/phpunit-arraysubset-asserts": "dev-master",
"ergebnis/composer-normalize": "^2.28",
"friendsofphp/php-cs-fixer": "3.86.0",
"mll-lab/php-cs-fixer-config": "5.11.0",
"nyholm/psr7": "^1.5",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "0.12.82",
"phpstan/phpstan-phpunit": "0.12.18",
"phpstan/phpstan-strict-rules": "0.12.9",
"phpunit/phpunit": "^7.2 || ^8.5",
"psr/http-message": "^1.0",
"react/promise": "2.*",
"simpod/php-coveralls-mirror": "^3.0"
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "2.1.22",
"phpstan/phpstan-phpunit": "2.0.7",
"phpstan/phpstan-strict-rules": "2.0.6",
"phpunit/phpunit": "^9.5 || ^10.5.21 || ^11",
"psr/http-message": "^1 || ^2",
"react/http": "^1.6",
"react/promise": "^2.0 || ^3.0",
"rector/rector": "^2.0",
"symfony/polyfill-php81": "^1.23",
"symfony/var-exporter": "^5 || ^6 || ^7",
"thecodingmachine/safe": "^1.3 || ^2 || ^3"
},
"suggest": {
"amphp/http-server": "To leverage async resolving with webserver on AMPHP platform",
"psr/http-message": "To use standard GraphQL server",
"react/promise": "To leverage async resolving on React PHP platform"
},
@@ -5468,7 +5477,7 @@
],
"support": {
"issues": "https://github.com/webonyx/graphql-php/issues",
"source": "https://github.com/webonyx/graphql-php/tree/v14.11.10"
"source": "https://github.com/webonyx/graphql-php/tree/v15.24.0"
},
"funding": [
{
@@ -5476,7 +5485,7 @@
"type": "open_collective"
}
],
"time": "2023-07-05T14:23:37+00:00"
"time": "2025-08-20T10:09:37+00:00"
}
],
"packages-dev": [
@@ -8988,7 +8997,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
@@ -9012,5 +9021,5 @@
"platform-overrides": {
"php": "8.3"
},
"plugin-api-version": "2.2.0"
"plugin-api-version": "2.9.0"
}
+1
View File
@@ -204,6 +204,7 @@ services:
- _APP_GRAPHQL_MAX_BATCH_SIZE
- _APP_GRAPHQL_MAX_COMPLEXITY
- _APP_GRAPHQL_MAX_DEPTH
- _APP_GRAPHQL_SCHEMA_CACHE_MB
- _APP_VCS_GITHUB_APP_NAME
- _APP_VCS_GITHUB_PRIVATE_KEY
- _APP_VCS_GITHUB_APP_ID
+345
View File
@@ -0,0 +1,345 @@
<?php
namespace Appwrite\GraphQL;
use GraphQL\Type\Schema as GQLSchema;
use Swoole\Lock;
use Swoole\Table;
/**
* LRU Cache for GraphQL Schemas keyed by project ID.
*
* Uses a combination of array storage and access tracking to implement
* least-recently-used eviction when the cache reaches memory capacity.
*
* This class is designed to be instantiated once per Swoole worker and
* registered for reuse across requests. Thread-safe via Swoole mutex locks.
*
* Dirty flags are stored in a shared Swoole Table to propagate cache
* invalidation across all workers.
*/
class Cache
{
/**
* @var array<string, GQLSchema> Cache storage: projectId => schema
*/
private array $cache = [];
/**
* @var array<string, int> Access timestamps: projectId => nanoseconds (hrtime)
*/
private array $accessTimes = [];
/**
* @var array<string, int> Memory usage per schema: projectId => bytes
*/
private array $memorySizes = [];
/**
* @var int Maximum cache size in bytes
*/
private int $maxBytes;
/**
* @var int Current total memory usage in bytes
*/
private int $currentBytes = 0;
/**
* @var Table|null Shared Swoole Table for dirty flags (shared across workers)
*/
private ?Table $dirty;
/**
* @var array<string, int> Local dirty flags (used when no shared table available)
*/
private array $local = [];
/**
* @var Lock Swoole mutex lock for thread safety within this worker
*/
private Lock $lock;
/**
* Heuristic constants for memory estimation.
* These are approximations - actual memory usage varies based on resolver closures,
* description lengths, and type complexity. The values are tuned for relative
* comparison between schemas rather than absolute accuracy.
*/
private const int BYTES_PER_TYPE = 2048; // ~2KB base per type
private const int BYTES_PER_FIELD = 768; // ~768 bytes per field (includes resolver overhead estimate)
/**
* Create a new cache instance.
*
* @param int $maxMB Maximum cache size in megabytes (default: 50)
* @param Table|null $dirty Shared Swoole Table for cross-worker dirty flag propagation
*/
public function __construct(int $maxMB = 50, ?Table $dirty = null)
{
$this->maxBytes = \max(1, $maxMB) * 1024 * 1024;
$this->dirty = $dirty;
$this->lock = new Lock(SWOOLE_MUTEX);
}
/**
* Configure the maximum cache size in megabytes.
*
* @param int $megabytes Maximum cache size in MB (minimum 1 MB)
*/
public function setMaxSizeMB(int $megabytes): void
{
$bytes = \max(1, $megabytes) * 1024 * 1024;
if ($this->maxBytes === $bytes) {
return;
}
$this->maxBytes = $bytes;
$this->evictIfNeeded();
}
/**
* Get the current max size in megabytes.
*/
public function getMaxSizeMB(): int
{
return (int) ($this->maxBytes / 1024 / 1024);
}
/**
* Get the current memory usage in bytes.
*/
public function getCurrentBytes(): int
{
return $this->currentBytes;
}
/**
* Get a schema from cache if it exists and is not dirty.
* Updates access time on hit.
*/
public function get(string $projectId): ?GQLSchema
{
$this->lock->lock();
try {
if ($this->isDirty($projectId)) {
$this->clearDirty($projectId);
if (isset($this->cache[$projectId])) {
$this->removeInternal($projectId);
}
return null;
}
if (!isset($this->cache[$projectId])) {
return null;
}
$this->accessTimes[$projectId] = \hrtime(true);
return $this->cache[$projectId];
} finally {
$this->lock->unlock();
}
}
/**
* Store a schema in the cache.
* Evicts least recently used entries if memory limit would be exceeded.
*/
public function set(string $projectId, GQLSchema $schema): void
{
$this->lock->lock();
try {
$this->clearDirty($projectId);
$schemaSize = $this->calculateSchemaSize($schema);
// Reject schemas larger than max cache size
if ($schemaSize > $this->maxBytes) {
return;
}
// Update existing entry
if (isset($this->cache[$projectId])) {
$oldSize = $this->memorySizes[$projectId] ?? 0;
$this->currentBytes = \max(0, $this->currentBytes - $oldSize) + $schemaSize;
$this->cache[$projectId] = $schema;
$this->memorySizes[$projectId] = $schemaSize;
$this->accessTimes[$projectId] = \hrtime(true);
return;
}
// Evict until we have room for the new schema
while ($this->currentBytes + $schemaSize > $this->maxBytes && !empty($this->cache)) {
$this->evictLRU();
}
$this->cache[$projectId] = $schema;
$this->memorySizes[$projectId] = $schemaSize;
$this->accessTimes[$projectId] = \hrtime(true);
$this->currentBytes += $schemaSize;
} finally {
$this->lock->unlock();
}
}
/**
* Calculate the memory size of a schema in bytes.
*
* Uses heuristic estimation for relative sizing in LRU eviction.
* Actual memory usage varies based on resolver closures, descriptions, and type complexity.
*/
private function calculateSchemaSize(GQLSchema $schema): int
{
$typeMap = $schema->getTypeMap();
$typeCount = \count($typeMap);
$fieldCount = 0;
foreach ($typeMap as $type) {
if (\method_exists($type, 'getFields')) {
$fieldCount += \count($type->getFields());
}
}
return ($typeCount * self::BYTES_PER_TYPE) + ($fieldCount * self::BYTES_PER_FIELD);
}
/**
* Mark a project's schema as dirty (needs rebuild).
* Uses shared Swoole Table to propagate across all workers when available,
* otherwise falls back to local array (for single-worker/test scenarios).
*/
public function setDirty(string $projectId): void
{
if ($this->dirty !== null) {
$this->dirty->set($projectId, ['timestamp' => \time()]);
} else {
$this->local[$projectId] = \time();
}
}
/**
* Check if a project's schema is dirty.
*/
public function isDirty(string $projectId): bool
{
if ($this->dirty !== null) {
return $this->dirty->exists($projectId);
}
return isset($this->local[$projectId]);
}
/**
* Clear a project's dirty flag (from shared table or local).
*/
private function clearDirty(string $projectId): void
{
if ($this->dirty !== null) {
$this->dirty->del($projectId);
} else {
unset($this->local[$projectId]);
}
}
/**
* Remove a specific project's schema from cache.
*/
public function remove(string $projectId): void
{
$this->lock->lock();
try {
$this->removeInternal($projectId);
} finally {
$this->lock->unlock();
}
}
/**
* Internal remove method (without locking - must be called within lock).
*/
private function removeInternal(string $projectId): void
{
if (isset($this->memorySizes[$projectId])) {
$this->currentBytes = \max(0, $this->currentBytes - $this->memorySizes[$projectId]);
}
unset($this->cache[$projectId]);
unset($this->accessTimes[$projectId]);
unset($this->memorySizes[$projectId]);
}
/**
* Clear all cached schemas.
*/
public function clear(): void
{
$this->lock->lock();
try {
$this->cache = [];
$this->accessTimes = [];
$this->memorySizes = [];
$this->local = [];
$this->currentBytes = 0;
} finally {
$this->lock->unlock();
}
}
/**
* Get current cache size (number of schemas).
*/
public function size(): int
{
return \count($this->cache);
}
/**
* Evict least recently used entries if over memory capacity.
*/
private function evictIfNeeded(): void
{
while ($this->currentBytes > $this->maxBytes && !empty($this->cache)) {
$this->evictLRU();
}
}
/**
* Evict the least recently used entry.
* Must be called within a locked context.
*/
private function evictLRU(): void
{
if (empty($this->accessTimes)) {
return;
}
$lruProject = \array_key_first($this->accessTimes);
$lruTime = $this->accessTimes[$lruProject];
foreach ($this->accessTimes as $projectId => $time) {
if ($time < $lruTime) {
$lruTime = $time;
$lruProject = $projectId;
}
}
$this->removeInternal($lruProject);
}
/**
* Get cache statistics for monitoring.
*
* @return array{schemas: int, memoryMB: float, maxMemoryMB: int, dirty: int}
*/
public function getStats(): array
{
return [
'schemas' => \count($this->cache),
'memoryMB' => \round($this->currentBytes / 1024 / 1024, 2),
'maxMemoryMB' => $this->getMaxSizeMB(),
'dirty' => $this->dirty !== null
? $this->dirty->count()
: \count($this->local),
];
}
}
-5
View File
@@ -11,9 +11,4 @@ class Exception extends AppwriteException implements ClientAware
{
return true;
}
public function getCategory(): string
{
return 'appwrite';
}
}
+13 -4
View File
@@ -73,16 +73,25 @@ abstract class Adapter implements PromiseAdapter
/**
* Create a new promise that is rejected with the given reason.
*
* @param mixed $reason
* @param \Throwable $reason
* @return GQLPromise
*/
abstract public function createRejected(mixed $reason): GQLPromise;
abstract public function createRejected(\Throwable $reason): GQLPromise;
/**
* Create a new promise that resolves when all passed in promises resolve.
*
* @param array $promisesOrValues
* @param iterable $promisesOrValues
* @return GQLPromise
*/
abstract public function all(array $promisesOrValues): GQLPromise;
abstract public function all(iterable $promisesOrValues): GQLPromise;
/**
* Synchronously wait for promise completion and return the result.
*
* @param GQLPromise $promise
* @return mixed
* @throws \Throwable
*/
abstract public function wait(GQLPromise $promise): mixed;
}
+110 -12
View File
@@ -4,39 +4,137 @@ namespace Appwrite\GraphQL\Promises\Adapter;
use Appwrite\GraphQL\Promises\Adapter;
use Appwrite\Promises\Swoole as SwoolePromise;
use GraphQL\Executor\Promise\Adapter\SyncPromise;
use GraphQL\Executor\Promise\Promise as GQLPromise;
class Swoole extends Adapter
{
/**
* Wait for promise completion and return the result.
*
* @param GQLPromise $promise
* @return mixed
* @throws \Throwable
*/
public function wait(GQLPromise $promise): mixed
{
/** @var SwoolePromise $swoolePromise */
$swoolePromise = $promise->adoptedPromise;
// Run both graphql-php's SyncPromise queue and our SwoolePromise queue
// graphql-php's Deferred uses SyncPromise::getQueue() internally
$syncQueue = SyncPromise::getQueue();
$swooleQueue = SwoolePromise::getQueue();
while ($swoolePromise->state === SwoolePromise::PENDING) {
// Run graphql-php's SyncPromise queue first (handles Deferred)
if (!$syncQueue->isEmpty()) {
SyncPromise::runQueue();
continue;
}
// Then run our SwoolePromise queue
if (!$swooleQueue->isEmpty()) {
SwoolePromise::runQueue();
continue;
}
// Both queues empty but promise still pending - this shouldn't happen
// in a properly resolved promise chain
break;
}
if ($swoolePromise->state === SwoolePromise::FULFILLED) {
return $swoolePromise->result;
}
if ($swoolePromise->state === SwoolePromise::REJECTED) {
throw $swoolePromise->result;
}
throw new \Exception('Could not resolve promise - still pending');
}
public function create(callable $resolver): GQLPromise
{
$promise = new SwoolePromise(function ($resolve, $reject) use ($resolver) {
$resolver($resolve, $reject);
});
// Create without executor - don't enqueue anything
$promise = new SwoolePromise();
try {
// Call resolver synchronously - it may call resolve/reject
$resolver(
[$promise, 'resolve'],
[$promise, 'reject']
);
} catch (\Throwable $e) {
$promise->reject($e);
}
return new GQLPromise($promise, $this);
}
public function createFulfilled($value = null): GQLPromise
{
$promise = new SwoolePromise(function ($resolve, $reject) use ($value) {
$resolve($value);
});
// Create without executor and resolve immediately (no coroutine)
$promise = new SwoolePromise();
$promise->resolve($value);
return new GQLPromise($promise, $this);
}
public function createRejected($reason): GQLPromise
public function createRejected(\Throwable $reason): GQLPromise
{
$promise = new SwoolePromise(function ($resolve, $reject) use ($reason) {
$reject($reason);
});
// Create without executor and reject immediately (no coroutine)
$promise = new SwoolePromise();
$promise->reject($reason);
return new GQLPromise($promise, $this);
}
public function all(array $promisesOrValues): GQLPromise
public function all(iterable $promisesOrValues): GQLPromise
{
return new GQLPromise(SwoolePromise::all($promisesOrValues), $this);
$promisesOrValues = \is_array($promisesOrValues) ? $promisesOrValues : \iterator_to_array($promisesOrValues);
$total = \count($promisesOrValues);
if ($total === 0) {
return $this->createFulfilled([]);
}
// Create the combined promise without executor
$combinedPromise = new SwoolePromise();
$count = 0;
$result = [];
$rejected = false;
$checkComplete = static function () use (&$count, $total, &$result, &$rejected, $combinedPromise): void {
if (!$rejected && $count === $total) {
\ksort($result);
$combinedPromise->resolve($result);
}
};
foreach ($promisesOrValues as $index => $promiseOrValue) {
if ($promiseOrValue instanceof GQLPromise) {
$result[$index] = null;
// Use GQLPromise::then() which goes through adapter->then()
// This matches SyncPromiseAdapter's behavior
$promiseOrValue->then(
static function ($value) use (&$result, $index, &$count, $checkComplete): void {
$result[$index] = $value;
++$count;
$checkComplete();
},
[$combinedPromise, 'reject']
);
} else {
$result[$index] = $promiseOrValue;
++$count;
}
}
$checkComplete();
return new GQLPromise($combinedPromise, $this);
}
}
+6 -4
View File
@@ -117,6 +117,7 @@ class Resolvers
* @param string $collectionId
* @param callable $url
* @param callable $params
* @param string $listKey The key in the response containing the list (e.g., 'documents' or 'rows')
* @return callable
*/
public static function documentList(
@@ -125,9 +126,10 @@ class Resolvers
string $collectionId,
callable $url,
callable $params,
string $listKey = 'documents',
): callable {
return static fn ($type, $args, $context, $info) => new Swoole(
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $type, $args) {
function (callable $resolve, callable $reject) use ($utopia, $databaseId, $collectionId, $url, $params, $listKey, $type, $args) {
$utopia = $utopia->getResource('utopia:graphql', true);
$request = $utopia->getResource('request', true);
$response = $utopia->getResource('response', true);
@@ -136,8 +138,8 @@ class Resolvers
$request->setURI($url($databaseId, $collectionId, $args));
$request->setQueryString($params($databaseId, $collectionId, $args));
$beforeResolve = function ($payload) {
return $payload['documents'];
$beforeResolve = function ($payload) use ($listKey) {
return $payload[$listKey];
};
self::resolve($utopia, $request, $response, $resolve, $reject, $beforeResolve);
@@ -286,7 +288,7 @@ class Resolvers
$payload = $beforeReject($payload);
}
$reject(new GQLException(
message: $payload['message'],
message: $payload['message'] ?? 'Server Error',
code: $response->getStatusCode()
));
return;
+280 -149
View File
@@ -3,30 +3,116 @@
namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Types\Mapper;
use Appwrite\GraphQL\Types\Registry;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema as GQLSchema;
use Utopia\App;
use Utopia\Exception;
use Utopia\Console;
use Utopia\Route;
class Schema
{
protected static ?GQLSchema $schema = null;
protected static array $dirty = [];
private Registry $registry;
private ?Mapper $mapper = null;
private string $projectId;
/**
* Reserved GraphQL type names that cannot be used for collection types.
*/
private const array RESERVED_TYPES = [
'Query', 'Mutation', 'Subscription',
'String', 'Int', 'Float', 'Boolean', 'ID',
'Input', 'Enum', '__Type', '__Field', '__InputValue',
'__EnumValue', '__Directive', '__Schema'
];
/**
* Sanitize a string to be a valid GraphQL name.
*
* GraphQL names must match /^[_A-Za-z][_0-9A-Za-z]*$/
* - Must start with a letter or underscore
* - Can only contain letters, digits, and underscores
* - Cannot start with two underscores (reserved for introspection)
*
* @param string $name The name to sanitize
* @return string The sanitized name
*/
private function sanitizeGraphQLName(string $name): string
{
// Replace any non-alphanumeric characters with underscores
$sanitized = \preg_replace('/[^A-Za-z0-9_]/', '_', $name);
// If the name starts with a digit, prefix with underscore
if (\preg_match('/^[0-9]/', $sanitized)) {
$sanitized = '_' . $sanitized;
}
// If the name starts with double underscore, prefix with 'u' to avoid
// collision with GraphQL introspection types (using '_' would still leave '__')
if (\str_starts_with($sanitized, '__')) {
$sanitized = 'u' . $sanitized;
}
// Ensure the name is not empty
if (empty($sanitized)) {
$sanitized = '_unnamed';
}
return $sanitized;
}
/**
* Create a new Schema instance.
*
* @param string $projectId The project ID for this schema
*/
public function __construct(string $projectId)
{
$this->projectId = $projectId;
$this->registry = new Registry($projectId);
}
/**
* Get the project ID.
*/
public function getProjectId(): string
{
return $this->projectId;
}
/**
* Get the registry instance.
*/
public function getRegistry(): Registry
{
return $this->registry;
}
/**
* Get the mapper instance
*/
public function getMapper(): ?Mapper
{
return $this->mapper;
}
/**
* Build a GraphQL schema for a specific project.
* Uses LRU cache for collection-based schemas.
*
* @param App $utopia
* @param callable $complexity Function to calculate complexity
* @param callable $attributes Function to get attributes
* @param array $urls Array of functions to get urls for specific method types
* @param array $params Array of functions to build parameters for specific method types
* @param Cache $cache The schema cache instance
* @param callable $complexity Function to calculate complexity
* @param callable $attributes Function to get attributes
* @param array $urls Array of functions to get urls for specific method types
* @param array $params Array of functions to build parameters for specific method types
* @return GQLSchema
* @throws Exception
* @throws \Exception
*/
public static function build(
public function build(
App $utopia,
Cache $cache,
callable $complexity,
callable $attributes,
array $urls,
@@ -36,44 +122,57 @@ class Schema
return $utopia;
});
if (!empty(self::$schema)) {
return self::$schema;
$cached = $cache->get($this->projectId);
if ($cached !== null) {
return $cached;
}
$api = static::api(
$utopia,
$complexity
);
//$collections = static::collections(
// $utopia,
// $complexity,
// $attributes,
// $urls,
// $params,
//);
try {
// Build API schema fresh for each Schema instance to ensure types are properly registered
// in this instance's Registry. The full schema is cached by projectId, so this only
// runs on cache miss.
$api = $this->api($utopia, $complexity);
$queries = \array_merge_recursive(
$api['query'],
//$collections['query']
);
$mutations = \array_merge_recursive(
$api['mutation'],
//$collections['mutation']
);
$collections = $this->collections(
$utopia,
$complexity,
$attributes,
$urls,
$params,
);
\ksort($queries);
\ksort($mutations);
$queries = \array_merge(
$api['query'],
$collections['query']
);
return static::$schema = new GQLSchema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => $queries
]),
'mutation' => new ObjectType([
'name' => 'Mutation',
'fields' => $mutations
])
]);
$mutations = \array_merge(
$api['mutation'],
$collections['mutation']
);
\ksort($queries);
\ksort($mutations);
$schema = new GQLSchema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => $queries
]),
'mutation' => new ObjectType([
'name' => 'Mutation',
'fields' => $mutations
])
]);
$cache->set($this->projectId, $schema);
return $schema;
} catch (\Throwable $e) {
// Clear registry on failure to prevent inconsistent state
$this->registry->clear();
throw $e;
}
}
/**
@@ -83,13 +182,12 @@ class Schema
* @param App $utopia
* @param callable $complexity
* @return array
* @throws Exception
* @throws \Exception
*/
protected static function api(App $utopia, callable $complexity): array
protected function api(App $utopia, callable $complexity): array
{
Mapper::init($utopia
->getResource('response')
->getModels());
$models = $utopia->getResource('response')->getModels();
$this->mapper = new Mapper($this->registry, $models);
$queries = [];
$mutations = [];
@@ -114,7 +212,7 @@ class Schema
$methodName = $method->getMethodName();
$name = $namespace . \ucfirst($methodName);
foreach (Mapper::route($utopia, $route, $method, $complexity) as $field) {
foreach ($this->mapper->route($utopia, $route, $method, $complexity) as $field) {
switch ($route->getMethod()) {
case 'GET':
$queries[$name] = $field;
@@ -126,7 +224,7 @@ class Schema
$mutations[$name] = $field;
break;
default:
throw new \Exception("Unsupported method: {$route->getMethod()}");
Console::warning("Unsupported method for GraphQL schema generation: {$route->getMethod()}");
}
}
}
@@ -140,18 +238,18 @@ class Schema
}
/**
* Iterates all of a projects attributes and builds GraphQL
* Iterates all of a project's attributes and builds GraphQL
* queries and mutations for the collections they make up.
*
* @param App $utopia
* @param callable $complexity
* @param callable $attributes
* @param array $urls
* @param array $params
* @return array
* @param callable(int $complexity, array $args): int $complexity
* @param callable(int $limit, string $last): array $attributes
* @param array<string, array<string, callable(string $databaseId, string $collectionId, array $args): string>> $urls
* @param array<string, array<string, callable(string $databaseId, string $collectionId, array $args): string>> $params
* @return array{query: array, mutation: array} Array containing query and mutation field definitions
* @throws \Exception
*/
protected static function collections(
protected function collections(
App $utopia,
callable $complexity,
callable $attributes,
@@ -162,23 +260,28 @@ class Schema
$queryFields = [];
$mutationFields = [];
$limit = 1000;
$offset = 0;
$last = null;
while (!empty($attrs = $attributes($limit, $offset))) {
while (!empty($attrs = $attributes($limit, $last))) {
foreach ($attrs as $attr) {
if ($attr['status'] !== 'available') {
continue;
}
$databaseId = $attr['databaseId'];
$collectionId = $attr['collectionId'];
$key = $attr['key'];
$type = $attr['type'];
$array = $attr['array'];
$required = $attr['required'];
$default = $attr['default'];
$escapedKey = str_replace('$', '', $key);
$collections[$collectionId][$escapedKey] = [
'type' => Mapper::attribute(
$databaseId = $attr->getAttribute('databaseId');
$collectionId = $attr->getAttribute('collectionId');
$databaseType = $attr->getAttribute('databaseType', 'legacy');
$key = $attr->getAttribute('key');
$type = $attr->getAttribute('type');
$array = $attr->getAttribute('array');
$required = $attr->getAttribute('required');
$default = $attr->getAttribute('default');
$escapedKey = \str_replace('$', '', $key);
// Use composite key for collection grouping
$collectionKey = "{$databaseId}_{$collectionId}";
$collections[$collectionKey]['databaseId'] = $databaseId;
$collections[$collectionKey]['collectionId'] = $collectionId;
$collections[$collectionKey]['databaseType'] = $databaseType;
$collections[$collectionKey]['attributes'][$escapedKey] = [
'type' => $this->mapper->attribute(
$type,
$array,
$required
@@ -187,82 +290,115 @@ class Schema
];
}
foreach ($collections as $collectionId => $attributes) {
$objectType = new ObjectType([
'name' => $collectionId,
'fields' => \array_merge(
["_id" => ['type' => Type::string()]],
$attributes
),
]);
$attributes = \array_merge(
$attributes,
Mapper::args('mutate')
);
// Use the last Document as cursor for pagination
$last = \end($attrs) ?: null;
}
$queryFields[$collectionId . 'Get'] = [
'type' => $objectType,
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentGet(
$utopia,
$databaseId,
$collectionId,
$urls['get'],
)
];
$queryFields[$collectionId . 'List'] = [
'type' => Type::listOf($objectType),
'args' => Mapper::args('list'),
'resolve' => Resolvers::documentList(
$utopia,
$databaseId,
$collectionId,
$urls['list'],
$params['list'],
),
'complexity' => $complexity,
];
foreach ($collections as $collectionData) {
$databaseId = $collectionData['databaseId'];
$collectionId = $collectionData['collectionId'];
$databaseType = $collectionData['databaseType'];
$attributes = $collectionData['attributes'];
$mutationFields[$collectionId . 'Create'] = [
'type' => $objectType,
'args' => $attributes,
'resolve' => Resolvers::documentCreate(
$utopia,
$databaseId,
$collectionId,
$urls['create'],
$params['create'],
)
];
$mutationFields[$collectionId . 'Update'] = [
'type' => $objectType,
'args' => \array_merge(
Mapper::args('id'),
\array_map(
fn ($attr) => $attr['type'] = Type::getNullableType($attr['type']),
$attributes
)
),
'resolve' => Resolvers::documentUpdate(
$utopia,
$databaseId,
$collectionId,
$urls['update'],
$params['update'],
)
];
$mutationFields[$collectionId . 'Delete'] = [
'type' => Mapper::model('none'),
'args' => Mapper::args('id'),
'resolve' => Resolvers::documentDelete(
$utopia,
$databaseId,
$collectionId,
$urls['delete'],
)
];
// Get URLs and params for this database type
$typeUrls = $urls[$databaseType] ?? $urls['legacy'];
$typeParams = $params[$databaseType] ?? $params['legacy'];
// Create unique type name for this project's collection
$sanitizedProjectId = $this->sanitizeGraphQLName($this->projectId);
$sanitizedCollectionId = $this->sanitizeGraphQLName($collectionId);
$typeName = $sanitizedProjectId . \ucfirst($sanitizedCollectionId);
if (\in_array($typeName, self::RESERVED_TYPES)) {
throw new \Exception("Type name collision with reserved type: {$typeName}");
}
$offset += $limit;
if ($this->registry->has($typeName)) {
throw new \Exception("Type name collision detected: {$typeName} already exists in registry");
}
$objectType = new ObjectType([
'name' => $typeName,
'fields' => \array_merge(
["_id" => ['type' => Type::string()]],
$attributes
),
]);
$mutateAttributes = \array_merge(
$attributes,
$this->mapper->args('mutate')
);
// Prefix field names with sanitized collection ID to avoid conflicts
$queryFields[$sanitizedCollectionId . 'Get'] = [
'type' => $objectType,
'args' => $this->mapper->args('id'),
'resolve' => Resolvers::documentGet(
$utopia,
$databaseId,
$collectionId,
$typeUrls['read'],
)
];
// Determine the list key based on database type (rows for tablesdb, documents for legacy)
$listKey = $databaseType === 'tablesdb'
? 'rows'
: 'documents';
$queryFields[$sanitizedCollectionId . 'List'] = [
'type' => Type::listOf($objectType),
'args' => $this->mapper->args('list'),
'resolve' => Resolvers::documentList(
$utopia,
$databaseId,
$collectionId,
$typeUrls['list'],
$typeParams['list'],
$listKey,
),
'complexity' => $complexity,
];
$mutationFields[$sanitizedCollectionId . 'Create'] = [
'type' => $objectType,
'args' => $mutateAttributes,
'resolve' => Resolvers::documentCreate(
$utopia,
$databaseId,
$collectionId,
$typeUrls['create'],
$typeParams['create'],
)
];
$mutationFields[$sanitizedCollectionId . 'Update'] = [
'type' => $objectType,
'args' => \array_merge(
$this->mapper->args('id'),
\array_map(
fn ($attr) => ['type' => Type::getNullableType($attr['type'])],
$mutateAttributes
)
),
'resolve' => Resolvers::documentUpdate(
$utopia,
$databaseId,
$collectionId,
$typeUrls['update'],
$typeParams['update'],
)
];
$mutationFields[$sanitizedCollectionId . 'Delete'] = [
'type' => $this->mapper->model('none'),
'args' => $this->mapper->args('id'),
'resolve' => Resolvers::documentDelete(
$utopia,
$databaseId,
$collectionId,
$typeUrls['delete'],
)
];
}
return [
@@ -270,9 +406,4 @@ class Schema
'mutation' => $mutationFields
];
}
public static function setDirty(string $projectId): void
{
self::$dirty[$projectId] = true;
}
}
+24 -28
View File
@@ -5,53 +5,49 @@ namespace Appwrite\GraphQL;
use Appwrite\GraphQL\Types\Assoc;
use Appwrite\GraphQL\Types\InputFile;
use Appwrite\GraphQL\Types\Json;
use Appwrite\GraphQL\Types\Registry;
use GraphQL\Type\Definition\Type;
class Types
{
/**
* Get the JSON type.
*
* @return Json
*/
public static function json(): Type
{
if (Registry::has(Json::class)) {
return Registry::get(Json::class);
}
$type = new Json();
Registry::set(Json::class, $type);
return $type;
}
private static ?Json $json = null;
private static ?Assoc $assoc = null;
private static ?InputFile $inputFile = null;
/**
* Get the JSON type.
*
* @return Json
* Thread-safety note: In Swoole, each worker is a separate process with its own
* static variables. Within a worker, coroutines are cooperative and only yield
* at I/O points. Since these constructors have no I/O, the null check and
* assignment execute atomically without needing locks.
*/
public static function json(): Type
{
if (self::$json === null) {
self::$json = new Json();
}
return self::$json;
}
/**
* Get the Assoc type.
*/
public static function assoc(): Type
{
if (Registry::has(Assoc::class)) {
return Registry::get(Assoc::class);
if (self::$assoc === null) {
self::$assoc = new Assoc();
}
$type = new Assoc();
Registry::set(Assoc::class, $type);
return $type;
return self::$assoc;
}
/**
* Get the InputFile type.
*
* @return InputFile
*/
public static function inputFile(): Type
{
if (Registry::has(InputFile::class)) {
return Registry::get(InputFile::class);
if (self::$inputFile === null) {
self::$inputFile = new InputFile();
}
$type = new InputFile();
Registry::set(InputFile::class, $type);
return $type;
return self::$inputFile;
}
}
+9 -3
View File
@@ -3,12 +3,18 @@
namespace Appwrite\GraphQL\Types;
use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\ScalarType;
// https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803
class Assoc extends Json
class Assoc extends ScalarType
{
public $name = 'Assoc';
public $description = 'The `Assoc` scalar type represents associative array values.';
public function __construct()
{
parent::__construct([
'name' => 'Assoc',
'description' => 'The `Assoc` scalar type represents associative array values.',
]);
}
public function serialize($value)
{
+7 -3
View File
@@ -8,9 +8,13 @@ use GraphQL\Type\Definition\ScalarType;
class InputFile extends ScalarType
{
public $name = 'InputFile';
public $description = 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by
[graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).';
public function __construct()
{
parent::__construct([
'name' => 'InputFile',
'description' => 'The `InputFile` special type represents a file to be uploaded in the same HTTP request as specified by [graphql-multipart-request-spec](https://github.com/jaydenseric/graphql-multipart-request-spec).',
]);
}
public function serialize($value)
{
+7 -3
View File
@@ -14,9 +14,13 @@ use GraphQL\Type\Definition\ScalarType;
// https://github.com/webonyx/graphql-php/issues/129#issuecomment-309366803
class Json extends ScalarType
{
public $name = 'Json';
public $description = 'The `JSON` scalar type represents JSON values as specified by
[ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).';
public function __construct()
{
parent::__construct([
'name' => 'Json',
'description' => 'The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).',
]);
}
public function serialize($value)
{
+104 -86
View File
@@ -16,19 +16,27 @@ use Utopia\Validator\Nullable;
class Mapper
{
private static array $models = [];
private static array $args = [];
private static array $blacklist = [
private Registry $registry;
private array $models;
private array $args;
private array $blacklist = [
'/v1/mock',
'/v1/graphql',
'/v1/account/sessions/oauth2',
];
public static function init(array $models): void
/**
* Create a new Mapper instance.
*
* @param Registry $registry The type registry instance
* @param array $models The response models
*/
public function __construct(Registry $registry, array $models)
{
self::$models = $models;
$this->registry = $registry;
$this->models = $models;
self::$args = [
$this->args = [
'id' => [
'id' => [
'type' => Type::nonNull(Type::string()),
@@ -41,6 +49,10 @@ class Mapper
],
],
'mutate' => [
'id' => [
'type' => Type::string(),
'defaultValue' => null,
],
'permissions' => [
'type' => Type::listOf(Type::nonNull(Type::string())),
'defaultValue' => [],
@@ -62,9 +74,7 @@ class Mapper
'enum' => Type::string()
];
foreach ($defaults as $type => $default) {
Registry::set($type, $default);
}
$this->registry->initBaseTypes($defaults);
}
/**
@@ -73,18 +83,27 @@ class Mapper
* @param string $key
* @return array
*/
public static function args(string $key): array
public function args(string $key): array
{
return self::$args[$key] ?? [];
return $this->args[$key] ?? [];
}
public static function route(
/**
* Map a route to GraphQL fields.
*
* @param App $utopia
* @param Route $route
* @param Method $method
* @param callable $complexity
* @return iterable<array> Iterator of GraphQL field definitions
*/
public function route(
App $utopia,
Route $route,
Method $method,
callable $complexity
): iterable {
foreach (self::$blacklist as $blacklist) {
foreach ($this->blacklist as $blacklist) {
if (\str_starts_with($route->getPath(), $blacklist)) {
return;
}
@@ -100,20 +119,20 @@ class Mapper
if (\is_array($modelName)) {
foreach ($modelName as $name) {
$models[] = static::$models[$name];
$models[] = $this->models[$name];
}
} else {
$models[] = static::$models[$modelName];
$models[] = $this->models[$modelName];
}
}
} else {
// If single response, get its model and wrap in array
$modelName = $responses->getModel();
$models = [static::$models[$modelName]];
$models = [$this->models[$modelName]];
}
foreach ($models as $model) {
$type = Mapper::model(\ucfirst($model->getType()));
$type = $this->model(\ucfirst($model->getType()));
$description = $route->getDesc();
$params = [];
$list = false;
@@ -140,7 +159,7 @@ class Mapper
$list = true;
}
$parameterType = Mapper::param(
$parameterType = $this->param(
$utopia,
$parameter['validator'],
!$optional,
@@ -173,14 +192,14 @@ class Mapper
* @param string $name
* @return Type
*/
public static function model(string $name): Type
public function model(string $name): Type
{
if (Registry::has($name)) {
return Registry::get($name);
if ($this->registry->has($name)) {
return $this->registry->get($name);
}
$fields = [];
$model = self::$models[\lcfirst($name)];
$model = $this->models[\lcfirst($name)];
// If model has additional properties, explicitly add a 'data' field
if ($model->isAny()) {
@@ -213,9 +232,9 @@ class Mapper
$escapedKey = str_replace('$', '_', $key);
if (\is_array($rule['type'])) {
$type = self::getUnionType($escapedKey, $rule);
$type = $this->getUnionType($escapedKey, $rule);
} else {
$type = self::getObjectType($rule);
$type = $this->getObjectType($rule);
}
if ($rule['array']) {
@@ -237,7 +256,7 @@ class Mapper
'fields' => $fields,
]);
Registry::set($name, $type);
$this->registry->set($name, $type);
return $type;
}
@@ -252,7 +271,7 @@ class Mapper
* @return Type
* @throws Exception
*/
public static function param(
public function param(
App $utopia,
Validator|callable $validator,
bool $required,
@@ -322,7 +341,7 @@ class Mapper
$type = Type::boolean();
break;
case 'Utopia\Validator\ArrayList':
$type = Type::listOf(self::param(
$type = Type::listOf($this->param(
$utopia,
$validator->getValidator(),
$required,
@@ -371,10 +390,10 @@ class Mapper
* @return Type
* @throws Exception
*/
public static function attribute(string $type, bool $array, bool $required): Type
public function attribute(string $type, bool $array, bool $required): Type
{
if ($array) {
return Type::listOf(self::attribute(
return Type::listOf($this->attribute(
$type,
false,
$required
@@ -395,103 +414,102 @@ class Mapper
return $type;
}
private static function getObjectType(array $rule): Type
private function getObjectType(array $rule): Type
{
$type = $rule['type'];
if (Registry::has($type)) {
return Registry::get($type);
if ($this->registry->has($type)) {
return $this->registry->get($type);
}
$complexModel = self::$models[$type];
return self::model(\ucfirst($complexModel->getType()));
$complexModel = $this->models[$type];
return $this->model(\ucfirst($complexModel->getType()));
}
private static function getUnionType(string $name, array $rule): Type
private function getUnionType(string $name, array $rule): Type
{
$unionName = \ucfirst($name);
if (Registry::has($unionName)) {
return Registry::get($unionName);
if ($this->registry->has($unionName)) {
return $this->registry->get($unionName);
}
$types = [];
foreach ($rule['type'] as $type) {
$types[] = self::model(\ucfirst($type));
$types[] = $this->model(\ucfirst($type));
}
// resolveType returns a string type name instead of a Type object.
// This ensures GraphQL looks up the type from the schema's type map,
// which is essential for cached schemas where the original type instances
// must be used (not newly created ones from calling model()).
$unionType = new UnionType([
'name' => $unionName,
'types' => $types,
'resolveType' => static function ($object) use ($unionName) {
return static::getUnionImplementation($unionName, $object);
return self::getUnionTypeName($unionName, $object);
},
]);
Registry::set($unionName, $unionType);
$this->registry->set($unionName, $unionType);
return $unionType;
}
private static function getUnionImplementation(string $name, array $object): Type
/**
* Get the type name for a union member based on the object data.
* Returns a string type name that GraphQL will look up in the schema.
*
* @param string $name The union type name
* @param array $object The object data
* @return string The type name
* @throws Exception
*/
public static function getUnionTypeName(string $name, array $object): string
{
// TODO: Find a better way to do this
switch ($name) {
case 'Attributes':
return static::getColumnImplementation($object);
case 'Columns':
return static::getColumnImplementation($object, true);
case 'HashOptions':
return static::getHashOptionsImplementation($object);
}
throw new Exception('Unknown union type: ' . $name);
return match ($name) {
'Attributes' => self::getColumnTypeName($object),
'Columns' => self::getColumnTypeName($object, true),
'HashOptions' => self::getHashOptionsTypeName($object),
default => throw new Exception('Unknown union type: ' . $name),
};
}
private static function getColumnImplementation(array $object, bool $isColumns = false): Type
private static function getColumnTypeName(array $object, bool $isColumns = false): string
{
$prefix = $isColumns ? 'Column' : 'Attribute';
return match ($object['type']) {
'string' => match ($object['format'] ?? '') {
'email' => static::model("{$prefix}Email"),
'url' => static::model("{$prefix}Url"),
'ip' => static::model("{$prefix}Ip"),
default => static::model("{$prefix}String"),
'email' => "{$prefix}Email",
'url' => "{$prefix}Url",
'ip' => "{$prefix}Ip",
default => "{$prefix}String",
},
'enum' => static::model("{$prefix}String"), // TODO: Add enum type (breaking change if added)
'integer' => static::model("{$prefix}Integer"),
'double' => static::model("{$prefix}Float"),
'boolean' => static::model("{$prefix}Boolean"),
'datetime' => static::model("{$prefix}Datetime"),
'relationship' => static::model("{$prefix}Relationship"),
'point' => static::model("{$prefix}Point"),
'linestring' => static::model("{$prefix}Line"),
'polygon' => static::model("{$prefix}Polygon"),
'enum' => "{$prefix}String", // TODO: Add enum type (breaking change if added)
'integer' => "{$prefix}Integer",
'double' => "{$prefix}Float",
'boolean' => "{$prefix}Boolean",
'datetime' => "{$prefix}Datetime",
'relationship' => "{$prefix}Relationship",
'point' => "{$prefix}Point",
'linestring' => "{$prefix}Line",
'polygon' => "{$prefix}Polygon",
default => throw new Exception('Unknown ' . strtolower($prefix) . ' implementation'),
};
}
private static function getHashOptionsImplementation(array $object): Type
private static function getHashOptionsTypeName(array $object): string
{
switch ($object['type']) {
case 'argon2':
return static::model('AlgoArgon2');
case 'bcrypt':
return static::model('AlgoBcrypt');
case 'md5':
return static::model('AlgoMd5');
case 'phpass':
return static::model('AlgoPhpass');
case 'scrypt':
return static::model('AlgoScrypt');
case 'scryptMod':
return static::model('AlgoScryptModified');
case 'sha':
return static::model('AlgoSha');
}
throw new Exception('Unknown hash options implementation');
return match ($object['type']) {
'argon2' => 'AlgoArgon2',
'bcrypt' => 'AlgoBcrypt',
'md5' => 'AlgoMd5',
'phpass' => 'AlgoPhpass',
'scrypt' => 'AlgoScrypt',
'scryptMod' => 'AlgoScryptModified',
'sha' => 'AlgoSha',
default => throw new Exception('Unknown hash options implementation'),
};
}
}
+107 -16
View File
@@ -6,38 +6,129 @@ use GraphQL\Type\Definition\Type;
class Registry
{
private static array $register = [];
/**
* @var array<string, Type> Per-project type storage
*/
private array $types = [];
/**
* Check if a type exists in the registry.
*
* @param string $type
* @return bool
* @var array<string, Type> Shared base types (boolean, string, etc.)
*/
public static function has(string $type): bool
private array $baseTypes = [];
/**
* @var string Current project context
*/
private string $projectId = '';
/**
* Create a new Registry instance.
*
* @param string $projectId The project ID for this registry
*/
public function __construct(string $projectId = '')
{
return isset(self::$register[$type]);
$this->projectId = $projectId;
}
/**
* Get the current project ID.
*/
public function getProjectId(): string
{
return $this->projectId;
}
/**
* Set the current project ID.
*/
public function setProjectId(string $projectId): void
{
$this->projectId = $projectId;
}
/**
* Check if a type exists in the registry (checks base types first, then project types).
*/
public function has(string $type): bool
{
return isset($this->baseTypes[$type]) || isset($this->types[$type]);
}
/**
* Get a type from the registry.
*
* @param string $type
* @return Type
*/
public static function get(string $type): Type
public function get(string $type): Type
{
return self::$register[$type];
if (isset($this->baseTypes[$type])) {
return $this->baseTypes[$type];
}
if (!isset($this->types[$type])) {
throw new \RuntimeException("Type '{$type}' not found in registry for project '{$this->projectId}'");
}
return $this->types[$type];
}
/**
* Set a type in the registry.
*
* @param string $type
* @param Type $typeObject
* @param string $type The type name
* @param Type $typeObject The type object
* @param bool $isBaseType If true, stores as a shared base type
*/
public static function set(string $type, Type $typeObject): void
public function set(string $type, Type $typeObject, bool $isBaseType = false): void
{
self::$register[$type] = $typeObject;
if ($isBaseType) {
$this->baseTypes[$type] = $typeObject;
} else {
$this->types[$type] = $typeObject;
}
}
/**
* Clear all project types (keeps base types by default).
*
* @param bool $includeBaseTypes If true, also clears base types
*/
public function clear(bool $includeBaseTypes = false): void
{
$this->types = [];
if ($includeBaseTypes) {
$this->baseTypes = [];
}
}
/**
* Initialize base types that are shared across all schemas.
*
* @param array<string, Type> $types
*/
public function initBaseTypes(array $types): void
{
foreach ($types as $name => $type) {
$this->baseTypes[$name] = $type;
}
}
/**
* Get all registered types (excluding base types).
*
* @return array<string, Type>
*/
public function getTypes(): array
{
return $this->types;
}
/**
* Get all base types.
*
* @return array<string, Type>
*/
public function getBaseTypes(): array
{
return $this->baseTypes;
}
}
@@ -30,7 +30,7 @@ class Update extends Action
->setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/databases/:databaseId')
->desc('Update database')
->groups(['api', 'database', 'schema'])
->groups(['api', 'database'])
->label('scope', 'databases.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].update')
@@ -33,7 +33,7 @@ class Create extends DatetimeCreate
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/datetime')
->desc('Create datetime column')
->groups(['api', 'database'])
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
@@ -33,7 +33,7 @@ class Create extends RelationshipCreate
->setHttpMethod(self::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/tablesdb/:databaseId/tables/:tableId/columns/relationship')
->desc('Create relationship column')
->groups(['api', 'database'])
->groups(['api', 'database', 'schema'])
->label('scope', ['tables.write', 'collections.write'])
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].tables.[tableId].columns.[columnId].create')
@@ -26,7 +26,7 @@ class Update extends DatabaseUpdate
->setHttpMethod(self::HTTP_REQUEST_METHOD_PUT)
->setHttpPath('/v1/tablesdb/:databaseId')
->desc('Update database')
->groups(['api', 'database', 'schema'])
->groups(['api', 'database'])
->label('scope', 'databases.write')
->label('resourceType', RESOURCE_TYPE_DATABASES)
->label('event', 'databases.[databaseId].update')
+45 -3
View File
@@ -167,7 +167,7 @@ abstract class Promise
*
* @return boolean
*/
protected function isPending(): bool
public function isPending(): bool
{
return $this->state == self::STATE_PENDING;
}
@@ -177,7 +177,7 @@ abstract class Promise
*
* @return boolean
*/
protected function isFulfilled(): bool
public function isFulfilled(): bool
{
return $this->state == self::STATE_FULFILLED;
}
@@ -187,8 +187,50 @@ abstract class Promise
*
* @return boolean
*/
protected function isRejected(): bool
public function isRejected(): bool
{
return $this->state == self::STATE_REJECTED;
}
/**
* Get the result value (only valid after promise is settled)
*
* @return mixed
*/
public function getResult(): mixed
{
return $this->result;
}
/**
* Resolve the promise with a value
*
* @param mixed $value
* @return self
*/
public function resolve(mixed $value): self
{
if ($this->state !== self::STATE_PENDING) {
return $this;
}
$this->setResult($value);
$this->setState(self::STATE_FULFILLED);
return $this;
}
/**
* Reject the promise with a reason
*
* @param mixed $reason
* @return self
*/
public function reject(mixed $reason): self
{
if ($this->state !== self::STATE_PENDING) {
return $this;
}
$this->setResult($reason);
$this->setState(self::STATE_REJECTED);
return $this;
}
}
+213 -43
View File
@@ -2,13 +2,67 @@
namespace Appwrite\Promises;
use Swoole\Coroutine\Channel;
/**
* Swoole-compatible promise implementation that uses deferred callback execution
* via a task queue, similar to graphql-php's SyncPromise.
*/
class Swoole extends Promise
{
public const PENDING = 'pending';
public const FULFILLED = 'fulfilled';
public const REJECTED = 'rejected';
public string $state = self::PENDING;
public mixed $result = null;
/**
* Promises created in `then` method of this promise and awaiting resolution
*
* @var array<array{self, callable|null, callable|null}>
*/
protected array $waiting = [];
/**
* Run all tasks in the queue
*/
public static function runQueue(): void
{
$q = self::getQueue();
while (!$q->isEmpty()) {
$task = $q->dequeue();
$task();
}
}
/**
* Get the shared task queue
*
* @return \SplQueue<callable(): void>
*/
public static function getQueue(): \SplQueue
{
static $queue;
return $queue ??= new \SplQueue();
}
public function __construct(?callable $executor = null)
{
parent::__construct($executor);
if ($executor === null) {
return;
}
// Enqueue the executor for deferred execution
self::getQueue()->enqueue(function () use ($executor): void {
try {
$executor(
fn ($value) => $this->resolve($value),
fn ($reason) => $this->reject($reason)
);
} catch (\Throwable $e) {
$this->reject($e);
}
});
}
protected function execute(
@@ -16,55 +70,171 @@ class Swoole extends Promise
callable $resolve,
callable $reject
): void {
\go(function () use ($executor, $resolve, $reject) {
try {
$executor($resolve, $reject);
} catch (\Throwable $exception) {
$reject($exception);
}
});
// Not used - we use the task queue mechanism
}
/**
* Returns a promise that completes when all passed in promises complete.
*
* @param iterable|Swoole[] $promises
* @return Promise
* Resolve the promise with a value
*/
public static function all(iterable $promises): Promise
public function resolve(mixed $value): self
{
return self::create(function (callable $resolve, callable $reject) use ($promises) {
$ticks = count($promises);
if ($this->state !== self::PENDING) {
return $this;
}
$result = [];
$error = null;
$channel = new Channel($ticks);
$key = 0;
// Handle thenable values
if (\is_object($value) && \method_exists($value, 'then')) {
$value->then(
fn ($v) => $this->resolve($v),
fn ($r) => $this->reject($r)
);
return $this;
}
foreach ($promises as $promise) {
$promise->then(function ($value) use ($key, &$result, $channel) {
$result[$key] = $value;
$channel->push(true);
return $value;
}, function ($err) use ($channel, &$error) {
$channel->push(true);
if ($error === null) {
$error = $err;
$this->state = self::FULFILLED;
$this->result = $value;
$this->enqueueWaitingPromises();
return $this;
}
/**
* Reject the promise with a reason
*/
public function reject(mixed $reason): self
{
if ($this->state !== self::PENDING) {
return $this;
}
$this->state = self::REJECTED;
$this->result = $reason;
$this->enqueueWaitingPromises();
return $this;
}
/**
* Enqueue callbacks for waiting promises to the task queue
*/
protected function enqueueWaitingPromises(): void
{
foreach ($this->waiting as [$promise, $onFulfilled, $onRejected]) {
self::getQueue()->enqueue(function () use ($promise, $onFulfilled, $onRejected): void {
if ($this->state === self::FULFILLED) {
try {
$promise->resolve($onFulfilled === null ? $this->result : $onFulfilled($this->result));
} catch (\Throwable $e) {
$promise->reject($e);
}
});
$key++;
}
while ($ticks--) {
$channel->pop();
}
$channel->close();
} elseif ($this->state === self::REJECTED) {
try {
if ($onRejected === null) {
$promise->reject($this->result);
} else {
$promise->resolve($onRejected($this->result));
}
} catch (\Throwable $e) {
$promise->reject($e);
}
}
});
}
if ($error !== null) {
$reject($error);
return;
}
$this->waiting = [];
}
$resolve($result);
});
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null
): self {
if ($this->state === self::REJECTED && $onRejected === null) {
return $this;
}
if ($this->state === self::FULFILLED && $onFulfilled === null) {
return $this;
}
$promise = new self();
$this->waiting[] = [$promise, $onFulfilled, $onRejected];
// If already settled, enqueue the callbacks for deferred execution
if ($this->state !== self::PENDING) {
$this->enqueueWaitingPromises();
}
return $promise;
}
public function isPending(): bool
{
return $this->state === self::PENDING;
}
public function isFulfilled(): bool
{
return $this->state === self::FULFILLED;
}
public function isRejected(): bool
{
return $this->state === self::REJECTED;
}
public function getResult(): mixed
{
return $this->result;
}
public static function all(iterable $promisesOrValues): self
{
$promisesOrValues = \is_array($promisesOrValues)
? $promisesOrValues
: \iterator_to_array($promisesOrValues);
$total = \count($promisesOrValues);
$all = new self();
if ($total === 0) {
$all->resolve([]);
return $all;
}
$count = 0;
$result = [];
$rejected = false;
$resolveAllWhenFinished = static function () use (&$count, $total, $all, &$result, &$rejected): void {
if (!$rejected && $count === $total) {
$all->resolve($result);
}
};
foreach ($promisesOrValues as $index => $promiseOrValue) {
if ($promiseOrValue instanceof self) {
$result[$index] = null;
$promiseOrValue->then(
static function ($value) use (&$result, $index, &$count, $resolveAllWhenFinished) {
$result[$index] = $value;
++$count;
$resolveAllWhenFinished();
},
static function ($error) use (&$rejected, $all) {
if (!$rejected) {
$rejected = true;
$all->reject($error);
}
}
);
} else {
$result[$index] = $promiseOrValue;
++$count;
}
}
$resolveAllWhenFinished();
return $all;
}
}
+27
View File
@@ -139,6 +139,33 @@ abstract class Scope extends TestCase
return $request;
}
/**
* Wait for all attributes in a collection to be available.
*
* @param string $databaseId
* @param string $collectionId
* @param int $timeoutMs Maximum time to wait in milliseconds
* @param int $waitMs Time between polling attempts in milliseconds
*/
protected function waitForAttributes(string $databaseId, string $collectionId, ?string $projectId = null, ?string $apiKey = null, int $timeoutMs = 10000, int $waitMs = 100): void
{
$projectId = $projectId ?? $this->getProject()['$id'];
$headers = $apiKey ? ['x-appwrite-key' => $apiKey] : $this->getHeaders();
$this->assertEventually(function () use ($databaseId, $collectionId, $projectId, $headers) {
$collection = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
$this->assertEquals(200, $collection['headers']['status-code']);
foreach ($collection['body']['attributes'] ?? [] as $attribute) {
$this->assertEquals('available', $attribute['status'], 'Attribute ' . $attribute['key'] . ' not available');
}
}, $timeoutMs, $waitMs);
}
/**
* @return array
*/
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -3,6 +3,7 @@
namespace Tests\Unit\GraphQL;
use Appwrite\GraphQL\Types\Mapper;
use Appwrite\GraphQL\Types\Registry;
use Appwrite\Utopia\Response;
use PHPUnit\Framework\TestCase;
use Swoole\Http\Response as SwooleResponse;
@@ -10,11 +11,13 @@ use Swoole\Http\Response as SwooleResponse;
class BuilderTest extends TestCase
{
protected ?Response $response = null;
protected ?Mapper $mapper = null;
public function setUp(): void
{
$this->response = new Response(new SwooleResponse());
Mapper::init($this->response->getModels());
$registry = new Registry('test-project');
$this->mapper = new Mapper($registry, $this->response->getModels());
}
/**
@@ -23,7 +26,7 @@ class BuilderTest extends TestCase
public function testCreateTypeMapping()
{
$model = $this->response->getModel(Response::MODEL_TABLE);
$type = Mapper::model(\ucfirst($model->getType()));
$type = $this->mapper->model(\ucfirst($model->getType()));
$this->assertEquals('Table', $type->name);
}
}
+764
View File
@@ -0,0 +1,764 @@
<?php
namespace Tests\Unit\GraphQL;
use Appwrite\GraphQL\Cache;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema as GQLSchema;
use PHPUnit\Framework\TestCase;
class CacheTest extends TestCase
{
private Cache $cache;
protected function setUp(): void
{
$this->cache = new Cache(1);
}
/**
* Create a mock schema with configurable number of types.
* Size calculation is based on type count (~4KB per type).
*/
private function createMockSchema(int $typeCount = 1, string $suffix = ''): GQLSchema
{
$types = [];
$queryFields = [];
for ($i = 0; $i < $typeCount; $i++) {
$typeName = "Type{$i}{$suffix}";
$types[$typeName] = new ObjectType([
'name' => $typeName,
'fields' => [
'id' => ['type' => Type::string()],
'name' => ['type' => Type::string()],
]
]);
$queryFields["get{$typeName}"] = ['type' => $types[$typeName]];
}
if (empty($queryFields)) {
$queryFields['dummy'] = ['type' => Type::string()];
}
return new GQLSchema([
'query' => new ObjectType([
'name' => 'Query' . $suffix,
'fields' => $queryFields
]),
'types' => \array_values($types)
]);
}
/**
* Create a large schema with many types (~400KB+).
*/
private function createLargeSchema(string $suffix = ''): GQLSchema
{
return $this->createMockSchema(100, $suffix);
}
// ============================================
// Basic Operations
// ============================================
public function testSetAndGet(): void
{
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->assertSame($schema, $this->cache->get('project1'));
$this->assertEquals(1, $this->cache->size());
}
public function testGetNonExistent(): void
{
$this->assertNull($this->cache->get('nonexistent'));
}
public function testGetAfterRemove(): void
{
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->cache->remove('project1');
$this->assertNull($this->cache->get('project1'));
}
public function testRemoveNonExistent(): void
{
// Should not throw
$this->cache->remove('nonexistent');
$this->assertEquals(0, $this->cache->size());
}
public function testRemoveMultipleTimes(): void
{
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->cache->remove('project1');
$this->cache->remove('project1'); // Second remove should be safe
$this->cache->remove('project1'); // Third remove should be safe
$this->assertEquals(0, $this->cache->size());
}
// ============================================
// Memory Tracking
// ============================================
public function testMemoryTracking(): void
{
$this->assertEquals(0, $this->cache->getCurrentBytes());
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->assertGreaterThan(0, $this->cache->getCurrentBytes());
}
public function testMemoryTrackingAccuracy(): void
{
$schema1 = $this->createMockSchema(10);
$schema2 = $this->createMockSchema(50);
$this->cache->set('project1', $schema1);
$bytes1 = $this->cache->getCurrentBytes();
$this->cache->set('project2', $schema2);
$bytes2 = $this->cache->getCurrentBytes();
// Larger schema should use more memory
$this->assertGreaterThan($bytes1, $bytes2);
$this->assertEquals(2, $this->cache->size());
}
public function testMemoryDecreasesOnRemove(): void
{
$schema = $this->createMockSchema(100);
$this->cache->set('project1', $schema);
$bytesWithSchema = $this->cache->getCurrentBytes();
$this->assertGreaterThan(0, $bytesWithSchema);
$this->cache->remove('project1');
$this->assertEquals(0, $this->cache->getCurrentBytes());
}
public function testMemoryDecreasesOnEviction(): void
{
$this->cache->setMaxSizeMB(10);
// Fill cache with enough schemas to exceed 1 MB
// Each large schema is ~83 KB, so 15+ schemas > 1 MB
for ($i = 1; $i <= 15; $i++) {
$this->cache->set("project{$i}", $this->createLargeSchema((string)$i));
}
$bytesBefore = $this->cache->getCurrentBytes();
$sizeBefore = $this->cache->size();
$this->assertGreaterThan(1024 * 1024, $bytesBefore, 'Cache should exceed 1 MB before eviction');
// Reduce limit to force eviction
$this->cache->setMaxSizeMB(1);
$bytesAfter = $this->cache->getCurrentBytes();
$sizeAfter = $this->cache->size();
$this->assertLessThan($bytesBefore, $bytesAfter);
$this->assertLessThan($sizeBefore, $sizeAfter);
$this->assertLessThanOrEqual(1024 * 1024, $bytesAfter, 'Cache should be at or under 1 MB after eviction');
}
// ============================================
// LRU Eviction
// ============================================
public function testLRUEvictionByMemory(): void
{
for ($i = 1; $i <= 20; $i++) {
$this->cache->set("project{$i}", $this->createLargeSchema());
}
$stats = $this->cache->getStats();
$this->assertLessThanOrEqual(1, $stats['memoryMB']);
}
public function testLRUEvictsOldestFirst(): void
{
$this->cache->setMaxSizeMB(1);
// Add schemas
$this->cache->set('oldest', $this->createLargeSchema('1'));
$this->cache->set('middle', $this->createLargeSchema('2'));
$this->cache->set('newest', $this->createLargeSchema('3'));
// Access middle to make it more recent than oldest
$this->cache->get('middle');
// Add more to trigger eviction
for ($i = 0; $i < 10; $i++) {
$this->cache->set("extra{$i}", $this->createLargeSchema((string)$i));
}
// Oldest should have been evicted first (assuming it wasn't accessed)
// Middle was accessed so it should survive longer
$this->assertNull($this->cache->get('oldest'));
}
public function testLRUAccessUpdatesTimestamp(): void
{
$this->cache->setMaxSizeMB(1);
$this->cache->set('project1', $this->createLargeSchema('1'));
$this->cache->set('project2', $this->createLargeSchema('2'));
// Access project1 repeatedly to keep it fresh
for ($i = 0; $i < 5; $i++) {
$this->cache->get('project1');
$this->cache->set("filler{$i}", $this->createLargeSchema((string)$i));
}
// project1 should still exist due to recent access
// project2 should have been evicted
$this->assertNotNull($this->cache->get('project1'));
}
public function testEvictionWithSingleEntry(): void
{
// Set very small cache
$cache = new Cache(1);
// Add a schema that's close to the limit
$cache->set('project1', $this->createLargeSchema('1'));
// Adding another large schema should evict the first
$cache->set('project2', $this->createLargeSchema('2'));
$this->assertEquals(1, $cache->getMaxSizeMB());
$stats = $cache->getStats();
$this->assertLessThanOrEqual(1, $stats['memoryMB']);
}
// ============================================
// Dirty Flag
// ============================================
public function testDirtyFlag(): void
{
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->cache->setDirty('project1');
$this->assertTrue($this->cache->isDirty('project1'));
$this->assertNull($this->cache->get('project1'));
$this->assertEquals(0, $this->cache->size());
}
public function testSetClearsDirtyFlag(): void
{
$this->cache->setDirty('project1');
$this->assertTrue($this->cache->isDirty('project1'));
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->assertFalse($this->cache->isDirty('project1'));
$this->assertNotNull($this->cache->get('project1'));
}
public function testDirtyFlagWithoutCacheEntry(): void
{
// Mark dirty without ever caching
$this->cache->setDirty('project1');
$this->assertTrue($this->cache->isDirty('project1'));
// Get should return null and clear dirty flag
$this->assertNull($this->cache->get('project1'));
$this->assertFalse($this->cache->isDirty('project1'));
}
public function testDirtyFlagClearedOnGet(): void
{
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->cache->setDirty('project1');
// First get clears dirty and removes entry
$this->assertNull($this->cache->get('project1'));
$this->assertFalse($this->cache->isDirty('project1'));
// Second get still returns null but no dirty flag
$this->assertNull($this->cache->get('project1'));
}
public function testMultipleDirtyFlags(): void
{
$this->cache->set('project1', $this->createMockSchema());
$this->cache->set('project2', $this->createMockSchema());
$this->cache->set('project3', $this->createMockSchema());
$this->cache->setDirty('project1');
$this->cache->setDirty('project3');
$this->assertTrue($this->cache->isDirty('project1'));
$this->assertFalse($this->cache->isDirty('project2'));
$this->assertTrue($this->cache->isDirty('project3'));
// Access dirty entries
$this->assertNull($this->cache->get('project1'));
$this->assertNotNull($this->cache->get('project2'));
$this->assertNull($this->cache->get('project3'));
$this->assertEquals(1, $this->cache->size());
}
public function testDirtyFlagIdempotent(): void
{
$this->cache->set('project1', $this->createMockSchema());
$this->cache->setDirty('project1');
$this->cache->setDirty('project1');
$this->cache->setDirty('project1');
$this->assertTrue($this->cache->isDirty('project1'));
$this->assertNull($this->cache->get('project1'));
}
// ============================================
// Update/Replace Operations
// ============================================
public function testUpdateExistingEntry(): void
{
$schema1 = $this->createMockSchema(10);
$schema2 = $this->createMockSchema(20);
$this->cache->set('project1', $schema1);
$bytesAfterFirst = $this->cache->getCurrentBytes();
$this->cache->set('project1', $schema2);
$bytesAfterSecond = $this->cache->getCurrentBytes();
$this->assertEquals(1, $this->cache->size());
$this->assertSame($schema2, $this->cache->get('project1'));
$this->assertGreaterThan($bytesAfterFirst, $bytesAfterSecond);
}
public function testUpdateWithSmallerSchema(): void
{
$schema1 = $this->createMockSchema(100);
$schema2 = $this->createMockSchema(10);
$this->cache->set('project1', $schema1);
$bytesAfterFirst = $this->cache->getCurrentBytes();
$this->cache->set('project1', $schema2);
$bytesAfterSecond = $this->cache->getCurrentBytes();
$this->assertEquals(1, $this->cache->size());
$this->assertLessThan($bytesAfterFirst, $bytesAfterSecond);
}
public function testRapidUpdates(): void
{
for ($i = 0; $i < 100; $i++) {
$this->cache->set('project1', $this->createMockSchema($i + 1));
}
$this->assertEquals(1, $this->cache->size());
$this->assertNotNull($this->cache->get('project1'));
}
// ============================================
// Clear Operations
// ============================================
public function testClear(): void
{
$this->cache->set('project1', $this->createMockSchema());
$this->cache->set('project2', $this->createMockSchema());
$this->cache->setDirty('project3');
$this->cache->clear();
$this->assertEquals(0, $this->cache->size());
$this->assertEquals(0, $this->cache->getCurrentBytes());
$this->assertNull($this->cache->get('project1'));
$this->assertNull($this->cache->get('project2'));
$this->assertFalse($this->cache->isDirty('project3'));
}
public function testClearEmptyCache(): void
{
$this->cache->clear();
$this->assertEquals(0, $this->cache->size());
$this->assertEquals(0, $this->cache->getCurrentBytes());
}
public function testClearAndReuse(): void
{
$this->cache->set('project1', $this->createMockSchema());
$this->cache->clear();
$schema = $this->createMockSchema();
$this->cache->set('project1', $schema);
$this->assertSame($schema, $this->cache->get('project1'));
$this->assertEquals(1, $this->cache->size());
}
// ============================================
// Stats
// ============================================
public function testGetStats(): void
{
$this->cache->set('project1', $this->createMockSchema());
$this->cache->setDirty('project2');
$stats = $this->cache->getStats();
$this->assertEquals(1, $stats['schemas']);
$this->assertArrayHasKey('memoryMB', $stats);
$this->assertGreaterThanOrEqual(0, $stats['memoryMB']);
$this->assertEquals(1, $stats['maxMemoryMB']);
$this->assertEquals(1, $stats['dirty']);
}
public function testStatsEmpty(): void
{
$stats = $this->cache->getStats();
$this->assertEquals(0, $stats['schemas']);
$this->assertEquals(0.0, $stats['memoryMB']);
$this->assertEquals(0, $stats['dirty']);
}
public function testStatsAfterEviction(): void
{
$this->cache->setMaxSizeMB(1);
for ($i = 1; $i <= 20; $i++) {
$this->cache->set("project{$i}", $this->createLargeSchema());
}
$stats = $this->cache->getStats();
$this->assertLessThanOrEqual(1, $stats['memoryMB']);
$this->assertLessThan(20, $stats['schemas']);
}
// ============================================
// Size Configuration
// ============================================
public function testMaxSizeMBChange(): void
{
$this->cache->setMaxSizeMB(10);
for ($i = 1; $i <= 5; $i++) {
$this->cache->set("project{$i}", $this->createLargeSchema());
}
$this->cache->setMaxSizeMB(1);
$stats = $this->cache->getStats();
$this->assertLessThanOrEqual(1, $stats['memoryMB']);
}
public function testGetMaxSizeMB(): void
{
$this->cache->setMaxSizeMB(50);
$this->assertEquals(50, $this->cache->getMaxSizeMB());
}
public function testMinimumMaxSize(): void
{
$this->cache->setMaxSizeMB(0);
$this->assertEquals(1, $this->cache->getMaxSizeMB());
$this->cache->setMaxSizeMB(-5);
$this->assertEquals(1, $this->cache->getMaxSizeMB());
}
public function testSetMaxSizeNoChangeSkipsEviction(): void
{
$this->cache->setMaxSizeMB(10);
$this->cache->set('project1', $this->createMockSchema());
$sizeBefore = $this->cache->size();
// Same value should not trigger eviction
$this->cache->setMaxSizeMB(10);
$this->assertEquals($sizeBefore, $this->cache->size());
}
public function testConstructorWithCustomSize(): void
{
$cache = new Cache(100);
$this->assertEquals(100, $cache->getMaxSizeMB());
}
public function testConstructorWithZeroSize(): void
{
$cache = new Cache(0);
$this->assertEquals(1, $cache->getMaxSizeMB()); // Minimum is 1
}
public function testConstructorWithNegativeSize(): void
{
$cache = new Cache(-10);
$this->assertEquals(1, $cache->getMaxSizeMB()); // Minimum is 1
}
// ============================================
// Multiple Instances
// ============================================
public function testMultipleCacheInstances(): void
{
$cache1 = new Cache(10);
$cache2 = new Cache(20);
$schema = $this->createMockSchema();
$cache1->set('project1', $schema);
$cache2->set('project1', $schema);
$this->assertEquals(1, $cache1->size());
$this->assertEquals(1, $cache2->size());
$cache1->remove('project1');
$this->assertEquals(0, $cache1->size());
$this->assertEquals(1, $cache2->size());
}
public function testInstancesHaveIndependentDirtyFlags(): void
{
$cache1 = new Cache(10);
$cache2 = new Cache(10);
$cache1->set('project1', $this->createMockSchema());
$cache2->set('project1', $this->createMockSchema());
$cache1->setDirty('project1');
$this->assertTrue($cache1->isDirty('project1'));
$this->assertFalse($cache2->isDirty('project1'));
}
public function testInstancesHaveIndependentMemoryTracking(): void
{
$cache1 = new Cache(10);
$cache2 = new Cache(10);
$cache1->set('project1', $this->createLargeSchema());
$this->assertGreaterThan(0, $cache1->getCurrentBytes());
$this->assertEquals(0, $cache2->getCurrentBytes());
}
// ============================================
// Edge Cases - Project IDs
// ============================================
public function testEmptyProjectId(): void
{
$schema = $this->createMockSchema();
$this->cache->set('', $schema);
$this->assertSame($schema, $this->cache->get(''));
$this->assertEquals(1, $this->cache->size());
}
public function testProjectIdWithSpecialCharacters(): void
{
$schema = $this->createMockSchema();
$specialIds = [
'project-with-dashes',
'project_with_underscores',
'project.with.dots',
'project:with:colons',
'project/with/slashes',
'project@with@at',
'project#with#hash',
'project with spaces',
"project\twith\ttabs",
];
foreach ($specialIds as $id) {
$this->cache->set($id, $schema);
$this->assertSame($schema, $this->cache->get($id), "Failed for ID: {$id}");
}
$this->assertEquals(count($specialIds), $this->cache->size());
}
public function testProjectIdWithUnicode(): void
{
$schema = $this->createMockSchema();
$unicodeIds = [
'プロジェクト', // Japanese
'项目', // Chinese
'مشروع', // Arabic
'проект', // Russian
'🚀project', // Emoji
];
foreach ($unicodeIds as $id) {
$this->cache->set($id, $schema);
$this->assertSame($schema, $this->cache->get($id), "Failed for ID: {$id}");
}
}
public function testVeryLongProjectId(): void
{
$schema = $this->createMockSchema();
$longId = str_repeat('a', 10000);
$this->cache->set($longId, $schema);
$this->assertSame($schema, $this->cache->get($longId));
}
public function testNumericProjectId(): void
{
$schema = $this->createMockSchema();
$this->cache->set('123456', $schema);
$this->assertSame($schema, $this->cache->get('123456'));
}
// ============================================
// Edge Cases - Stress Tests
// ============================================
public function testManySmallSchemas(): void
{
$this->cache->setMaxSizeMB(10);
for ($i = 0; $i < 1000; $i++) {
$this->cache->set("project{$i}", $this->createMockSchema(1));
}
// Should have cached many small schemas
$this->assertGreaterThan(100, $this->cache->size());
}
public function testAlternatingSetAndGet(): void
{
for ($i = 0; $i < 100; $i++) {
$projectId = "project" . ($i % 10);
$this->cache->set($projectId, $this->createMockSchema($i + 1));
$this->assertNotNull($this->cache->get($projectId));
}
$this->assertGreaterThan(0, $this->cache->size());
$this->assertLessThanOrEqual(10, $this->cache->size());
}
public function testAlternatingDirtyAndSet(): void
{
for ($i = 0; $i < 50; $i++) {
$this->cache->set('project1', $this->createMockSchema($i + 1));
$this->cache->setDirty('project1');
$this->assertNull($this->cache->get('project1'));
}
$this->assertEquals(0, $this->cache->size());
}
// ============================================
// Edge Cases - Boundary Conditions
// ============================================
public function testSchemaExactlyAtMemoryLimit(): void
{
// This is tricky to test exactly, but we can verify behavior
$cache = new Cache(1); // 1 MB limit
// Add schemas until we hit the limit
$added = 0;
while ($cache->getCurrentBytes() < 1024 * 1024 && $added < 100) {
$cache->set("project{$added}", $this->createMockSchema(50));
$added++;
}
// Should have evicted some if over limit
$stats = $cache->getStats();
$this->assertLessThanOrEqual(1, $stats['memoryMB']);
}
public function testEvictionWhenNewSchemaLargerThanLimit(): void
{
$cache = new Cache(1); // 1 MB limit
// Add a small schema first
$cache->set('small', $this->createMockSchema(10));
// Try to add a very large schema (should evict small one)
$largeSchema = $this->createLargeSchema();
$cache->set('large', $largeSchema);
// Large schema should be cached (even if close to limit)
$this->assertNotNull($cache->get('large'));
}
// ============================================
// Regression Tests
// ============================================
public function testDirtyFlagDoesNotLeakMemory(): void
{
// Set dirty for many non-existent projects
for ($i = 0; $i < 1000; $i++) {
$this->cache->setDirty("nonexistent{$i}");
}
$stats = $this->cache->getStats();
$this->assertEquals(1000, $stats['dirty']);
// Clear should remove all dirty flags
$this->cache->clear();
$stats = $this->cache->getStats();
$this->assertEquals(0, $stats['dirty']);
}
public function testRemoveDoesNotAffectOtherEntries(): void
{
$schema1 = $this->createMockSchema(10);
$schema2 = $this->createMockSchema(20);
$schema3 = $this->createMockSchema(30);
$this->cache->set('project1', $schema1);
$this->cache->set('project2', $schema2);
$this->cache->set('project3', $schema3);
$this->cache->remove('project2');
$this->assertSame($schema1, $this->cache->get('project1'));
$this->assertNull($this->cache->get('project2'));
$this->assertSame($schema3, $this->cache->get('project3'));
}
public function testSetDirtyDoesNotAffectOtherEntries(): void
{
$schema1 = $this->createMockSchema();
$schema2 = $this->createMockSchema();
$this->cache->set('project1', $schema1);
$this->cache->set('project2', $schema2);
$this->cache->setDirty('project1');
$this->assertNull($this->cache->get('project1'));
$this->assertSame($schema2, $this->cache->get('project2'));
}
}
+444
View File
@@ -0,0 +1,444 @@
<?php
namespace Tests\Unit\GraphQL;
use Appwrite\GraphQL\Types\Registry;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
use PHPUnit\Framework\TestCase;
class RegistryTest extends TestCase
{
// ============================================
// Constructor and Project ID
// ============================================
public function testConstructorWithProjectId(): void
{
$registry = new Registry('myProject');
$this->assertEquals('myProject', $registry->getProjectId());
}
public function testConstructorWithoutProjectId(): void
{
$registry = new Registry();
$this->assertEquals('', $registry->getProjectId());
}
public function testSetProjectId(): void
{
$registry = new Registry('project1');
$registry->setProjectId('project2');
$this->assertEquals('project2', $registry->getProjectId());
}
// ============================================
// Base Types
// ============================================
public function testInitBaseTypes(): void
{
$registry = new Registry('project1');
$registry->initBaseTypes([
'string' => Type::string(),
'boolean' => Type::boolean(),
]);
$this->assertTrue($registry->has('string'));
$this->assertTrue($registry->has('boolean'));
$this->assertSame(Type::string(), $registry->get('string'));
}
public function testBaseTypesNotAffectedByClear(): void
{
$registry = new Registry('project1');
$registry->initBaseTypes(['baseType' => Type::string()]);
$registry->set('customType', Type::int());
$registry->clear();
$this->assertTrue($registry->has('baseType'));
$this->assertFalse($registry->has('customType'));
}
public function testClearWithIncludeBaseTypes(): void
{
$registry = new Registry('project1');
$registry->initBaseTypes(['baseType' => Type::string()]);
$registry->set('customType', Type::int());
$registry->clear(true);
$this->assertFalse($registry->has('baseType'));
$this->assertFalse($registry->has('customType'));
}
public function testInitBaseTypesMultipleTimes(): void
{
$registry = new Registry('project1');
$registry->initBaseTypes(['type1' => Type::string()]);
$registry->initBaseTypes(['type2' => Type::int()]);
$this->assertTrue($registry->has('type1'));
$this->assertTrue($registry->has('type2'));
}
public function testBaseTypesOverwrite(): void
{
$registry = new Registry('project1');
$originalType = Type::string();
$newType = Type::int();
$registry->initBaseTypes(['shared' => $originalType]);
$registry->initBaseTypes(['shared' => $newType]);
$this->assertSame($newType, $registry->get('shared'));
}
// ============================================
// Instance Isolation
// ============================================
public function testInstanceIsolation(): void
{
$registry1 = new Registry('project1');
$registry2 = new Registry('project2');
$type1 = Type::string();
$type2 = Type::int();
$registry1->set('customType', $type1);
$registry2->set('customType', $type2);
$this->assertSame($type1, $registry1->get('customType'));
$this->assertSame($type2, $registry2->get('customType'));
}
public function testInstanceTypesNotVisibleToOtherInstances(): void
{
$registry1 = new Registry('project1');
$registry2 = new Registry('project2');
$registry1->set('project1OnlyType', Type::string());
$this->assertFalse($registry2->has('project1OnlyType'));
}
public function testMultipleTypesPerInstance(): void
{
$registry = new Registry('project1');
$registry->set('type1', Type::string());
$registry->set('type2', Type::int());
$registry->set('type3', Type::boolean());
$this->assertTrue($registry->has('type1'));
$this->assertTrue($registry->has('type2'));
$this->assertTrue($registry->has('type3'));
}
// ============================================
// Clear Operations
// ============================================
public function testClear(): void
{
$registry = new Registry('project1');
$registry->set('type1', Type::string());
$registry->set('type2', Type::int());
$registry->clear();
$this->assertFalse($registry->has('type1'));
$this->assertFalse($registry->has('type2'));
}
public function testClearMultipleTimes(): void
{
$registry = new Registry('project1');
$registry->set('type1', Type::string());
$registry->clear();
$registry->clear();
$registry->clear();
$this->assertFalse($registry->has('type1'));
}
// ============================================
// Get/Set Operations
// ============================================
public function testSetAndGet(): void
{
$registry = new Registry('project1');
$type = Type::string();
$registry->set('myType', $type);
$this->assertSame($type, $registry->get('myType'));
}
public function testSetOverwrites(): void
{
$registry = new Registry('project1');
$type1 = Type::string();
$type2 = Type::int();
$registry->set('myType', $type1);
$registry->set('myType', $type2);
$this->assertSame($type2, $registry->get('myType'));
}
public function testHas(): void
{
$registry = new Registry('project1');
$this->assertFalse($registry->has('nonexistent'));
$registry->set('exists', Type::string());
$this->assertTrue($registry->has('exists'));
}
public function testGetThrowsForNonExistentType(): void
{
$registry = new Registry('project1');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("Type 'nonexistent' not found in registry for project 'project1'");
$registry->get('nonexistent');
}
// ============================================
// Complex Types
// ============================================
public function testObjectType(): void
{
$registry = new Registry('project1');
$objectType = new ObjectType([
'name' => 'CustomObject',
'fields' => [
'id' => ['type' => Type::id()],
'name' => ['type' => Type::string()],
]
]);
$registry->set('CustomObject', $objectType);
$this->assertSame($objectType, $registry->get('CustomObject'));
}
public function testListType(): void
{
$registry = new Registry('project1');
$listType = Type::listOf(Type::string());
$registry->set('StringList', $listType);
$this->assertSame($listType, $registry->get('StringList'));
}
public function testNonNullType(): void
{
$registry = new Registry('project1');
$nonNullType = Type::nonNull(Type::string());
$registry->set('NonNullString', $nonNullType);
$this->assertSame($nonNullType, $registry->get('NonNullString'));
}
// ============================================
// Edge Cases - Type Names
// ============================================
public function testEmptyTypeName(): void
{
$registry = new Registry('project1');
$type = Type::string();
$registry->set('', $type);
$this->assertTrue($registry->has(''));
$this->assertSame($type, $registry->get(''));
}
public function testTypeNameWithSpecialCharacters(): void
{
$registry = new Registry('project1');
$type = Type::string();
$specialNames = [
'Type-With-Dashes',
'Type_With_Underscores',
'Type.With.Dots',
'Type:With:Colons',
'Type With Spaces',
];
foreach ($specialNames as $name) {
$registry->set($name, $type);
$this->assertTrue($registry->has($name), "Failed for name: {$name}");
$this->assertSame($type, $registry->get($name), "Failed to get: {$name}");
}
}
public function testVeryLongTypeName(): void
{
$registry = new Registry('project1');
$type = Type::string();
$longName = str_repeat('a', 10000);
$registry->set($longName, $type);
$this->assertTrue($registry->has($longName));
$this->assertSame($type, $registry->get($longName));
}
// ============================================
// isBaseType Parameter
// ============================================
public function testSetWithIsBaseTypeTrue(): void
{
$registry = new Registry('project1');
$type = Type::string();
$registry->set('sharedType', $type, true);
// Base types should be in getBaseTypes()
$baseTypes = $registry->getBaseTypes();
$this->assertArrayHasKey('sharedType', $baseTypes);
$this->assertSame($type, $baseTypes['sharedType']);
}
public function testSetWithIsBaseTypeFalse(): void
{
$registry = new Registry('project1');
$type = Type::string();
$registry->set('projectType', $type, false);
// Project types should be in getTypes()
$types = $registry->getTypes();
$this->assertArrayHasKey('projectType', $types);
$this->assertSame($type, $types['projectType']);
// Should not be in base types
$baseTypes = $registry->getBaseTypes();
$this->assertArrayNotHasKey('projectType', $baseTypes);
}
// ============================================
// getTypes and getBaseTypes
// ============================================
public function testGetTypes(): void
{
$registry = new Registry('project1');
$type1 = Type::string();
$type2 = Type::int();
$registry->set('type1', $type1);
$registry->set('type2', $type2);
$types = $registry->getTypes();
$this->assertCount(2, $types);
$this->assertSame($type1, $types['type1']);
$this->assertSame($type2, $types['type2']);
}
public function testGetBaseTypes(): void
{
$registry = new Registry('project1');
$registry->initBaseTypes([
'string' => Type::string(),
'int' => Type::int(),
]);
$baseTypes = $registry->getBaseTypes();
$this->assertCount(2, $baseTypes);
$this->assertSame(Type::string(), $baseTypes['string']);
$this->assertSame(Type::int(), $baseTypes['int']);
}
// ============================================
// Stress Tests
// ============================================
public function testManyTypesInOneRegistry(): void
{
$registry = new Registry('project1');
for ($i = 0; $i < 1000; $i++) {
$registry->set("type{$i}", Type::string());
}
for ($i = 0; $i < 1000; $i++) {
$this->assertTrue($registry->has("type{$i}"), "Failed for type{$i}");
}
}
public function testManyRegistryInstances(): void
{
$registries = [];
for ($i = 0; $i < 100; $i++) {
$registries[$i] = new Registry("project{$i}");
$registries[$i]->set('projectSpecificType', Type::string());
}
// Verify each registry has its type
for ($i = 0; $i < 100; $i++) {
$this->assertTrue($registries[$i]->has('projectSpecificType'));
}
}
// ============================================
// Regression Tests
// ============================================
public function testBaseTypeTakesPrecedenceOverProjectType(): void
{
$registry = new Registry('project1');
$baseType = Type::string();
$projectType = Type::int();
$registry->initBaseTypes(['sharedName' => $baseType]);
// This should go to project types, not overwrite base type
$registry->set('sharedName', $projectType, false);
// Base type should still be returned (checked first)
$this->assertSame($baseType, $registry->get('sharedName'));
}
public function testSetAfterClear(): void
{
$registry = new Registry('project1');
$registry->set('type1', Type::string());
$registry->clear();
// Should work normally after clear
$registry->set('type2', Type::int());
$this->assertTrue($registry->has('type2'));
$this->assertFalse($registry->has('type1'));
}
}