mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Compare commits
29
Commits
@@ -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=
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,4 @@ class Exception extends AppwriteException implements ClientAware
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getCategory(): string
|
||||
{
|
||||
return 'appwrite';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user