Files
appwrite/tests/e2e/Services/GraphQL/SchemaIsolationTest.php
2026-01-21 01:32:34 +13:00

1467 lines
63 KiB
PHP

<?php
namespace Tests\E2E\Services\GraphQL;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
/**
* Tests for GraphQL schema caching and per-project isolation.
*
* These tests verify:
* 1. Schemas are correctly built and cached per project
* 2. Schema changes (new attributes) are reflected after cache invalidation
* 3. Multiple projects have isolated schemas (collection types don't leak)
* 4. Concurrent requests to same project use consistent schema
*/
class SchemaIsolationTest extends Scope
{
use ProjectCustom;
use SideServer;
use Base;
/**
* Test basic GraphQL query works and schema is built.
*/
public function testSchemaBuildsSuccessfully(): void
{
$projectId = $this->getProject()['$id'];
// Simple introspection-like query to verify schema exists
$query = 'query { healthGet { status } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayNotHasKey('errors', $response['body']);
}
/**
* Test that repeated queries use cached schema (no errors from schema rebuild).
*/
public function testRepeatedQueriesWork(): void
{
$projectId = $this->getProject()['$id'];
$query = 'query { healthGet { status } }';
// Make multiple requests - all should succeed using cached schema
for ($i = 0; $i < 5; $i++) {
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query
]);
$this->assertEquals(200, $response['headers']['status-code'], "Request $i failed");
$this->assertArrayHasKey('data', $response['body'], "Request $i missing data");
$this->assertArrayNotHasKey('errors', $response['body'], "Request $i has errors");
}
}
/**
* Test that dynamic collection schema is reused across multiple requests.
* This verifies the schema cache works for project-specific collection types.
* Uses a fresh project to ensure clean schema cache (Swoole workers have isolated caches).
*/
public function testDynamicSchemaIsReused(): void
{
// Use a fresh project to ensure clean schema cache
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create a database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Cache Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'cacheTest',
'name' => 'Cache Test Collection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Create an attribute
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/cacheTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'name',
'size' => 255,
'required' => true,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for attribute to be available
$this->waitForAttributes($databaseId, 'cacheTest', $projectId, $freshProject['apiKey']);
// Create a document
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/cacheTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['name' => 'Test Item'],
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $document['headers']['status-code']);
// Query using the dynamic schema multiple times - all should work with cached schema
$dynamicQuery = 'query { cacheTestList { _id name } }';
for ($i = 0; $i < 10; $i++) {
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'query' => $dynamicQuery,
]);
$this->assertEquals(200, $response['headers']['status-code'], "Request $i failed");
$this->assertArrayNotHasKey('errors', $response['body'], "Request $i has errors: " . json_encode($response['body']['errors'] ?? []));
$this->assertArrayHasKey('data', $response['body'], "Request $i missing data");
$this->assertArrayHasKey('cacheTestList', $response['body']['data'], "Request $i missing cacheTestList");
$this->assertNotEmpty($response['body']['data']['cacheTestList'], "Request $i returned empty list");
$this->assertEquals('Test Item', $response['body']['data']['cacheTestList'][0]['name'], "Request $i returned wrong data");
}
}
/**
* Test schema reflects collection attributes after they're created.
*/
public function testSchemaReflectsNewAttributes(): array
{
$projectId = $this->getProject()['$id'];
// Create a database
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Schema Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create a collection
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'collectionId' => 'testCollection',
'name' => 'Test Collection',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
Permission::update(Role::users()),
Permission::delete(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$collectionId = $collection['body']['$id'];
// Create an attribute
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'key' => 'title',
'size' => 255,
'required' => true,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for attribute to be available
$this->waitForAttributes($databaseId, $collectionId);
// Create a document using REST API
$document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'documentId' => ID::unique(),
'data' => [
'title' => 'Test Document',
],
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $document['headers']['status-code']);
// Query the document via GraphQL - schema should include our collection
$query = $this->getQuery(self::GET_DOCUMENTS);
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayNotHasKey('errors', $response['body']);
return [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'documentId' => $document['body']['$id'],
];
}
/**
* @depends testSchemaReflectsNewAttributes
* Test schema updates when new attribute is added to existing collection.
*/
public function testSchemaUpdatesOnAttributeChange(array $data): void
{
$projectId = $this->getProject()['$id'];
$databaseId = $data['databaseId'];
$collectionId = $data['collectionId'];
// Add a new attribute
$attribute = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'key' => 'description',
'size' => 1000,
'required' => false,
]);
$this->assertEquals(202, $attribute['headers']['status-code']);
// Wait for attribute to be available
$this->waitForAttributes($databaseId, $collectionId);
// Update existing document with new field
$document = $this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $data['documentId'], array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'data' => [
'description' => 'This is a test description',
],
]);
$this->assertEquals(200, $document['headers']['status-code']);
// Query via GraphQL - schema should now include the new attribute
$query = $this->getQuery(self::GET_DOCUMENT);
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $databaseId,
'collectionId' => $collectionId,
'documentId' => $data['documentId'],
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayNotHasKey('errors', $response['body']);
}
/**
* Test that two different projects have isolated schemas.
* Creating a collection in project A should not affect project B.
*/
public function testProjectSchemaIsolation(): void
{
// Get or create first project
$project1 = $this->getProject();
$project1Id = $project1['$id'];
// Create second project
$project2 = $this->getProject(true); // fresh = true
$project2Id = $project2['$id'];
$this->assertNotEquals($project1Id, $project2Id, 'Projects should be different');
// Create a database and collection in project 1
$database1 = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $project1Id,
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Project1 DB',
]);
$this->assertEquals(201, $database1['headers']['status-code']);
$db1Id = $database1['body']['$id'];
$collection1 = $this->client->call(Client::METHOD_POST, '/databases/' . $db1Id . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $project1Id,
], $this->getHeaders()), [
'collectionId' => 'isolatedCollection',
'name' => 'Isolated Collection',
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $collection1['headers']['status-code']);
// Create attribute in project 1's collection
$attr = $this->client->call(Client::METHOD_POST, '/databases/' . $db1Id . '/collections/isolatedCollection/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $project1Id,
], $this->getHeaders()), [
'key' => 'project1Only',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr['headers']['status-code']);
// Wait for attribute to be available
$this->waitForAttributes($db1Id, 'isolatedCollection');
// Query project 1 - should work
$query = $this->getQuery(self::GET_COLLECTIONS);
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $project1Id,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $db1Id,
],
]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
// Query project 2 with same database ID - should fail (database doesn't exist there)
$response2 = $this->client->call(Client::METHOD_POST, '/graphql', [
'content-type' => 'application/json',
'x-appwrite-project' => $project2Id,
'x-appwrite-key' => $project2['apiKey'],
], [
'query' => $query,
'variables' => [
'databaseId' => $db1Id,
],
]);
$this->assertEquals(200, $response2['headers']['status-code']);
// Should have an error because database doesn't exist in project 2
$this->assertArrayHasKey('errors', $response2['body']);
}
/**
* Test batched GraphQL queries work with cached schema.
*/
public function testBatchedQueriesWork(): void
{
$projectId = $this->getProject()['$id'];
// Send batched queries
$queries = [
['query' => 'query { healthGet { status } }'],
['query' => 'query { healthGetDB { status } }'],
['query' => 'query { healthGetCache { status } }'],
];
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), $queries);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsArray($response['body']);
$this->assertCount(3, $response['body']);
foreach ($response['body'] as $i => $result) {
$this->assertArrayHasKey('data', $result, "Batch query $i missing data");
$this->assertArrayNotHasKey('errors', $result, "Batch query $i has errors");
}
}
/**
* Test schema handles collection deletion gracefully.
*/
public function testSchemaHandlesCollectionDeletion(): void
{
$projectId = $this->getProject()['$id'];
// Create a database
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Deletion Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create a collection
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'collectionId' => 'toDelete',
'name' => 'To Delete',
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Add attribute
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/toDelete/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'key' => 'name',
'size' => 100,
'required' => false,
]);
// Wait for attribute to be available
$this->waitForAttributes($databaseId, 'toDelete');
// Query collection - should work
$query = $this->getQuery(self::GET_DOCUMENTS);
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $databaseId,
'collectionId' => 'toDelete',
],
]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
// Delete the collection
$deleteResponse = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/toDelete', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()));
$this->assertEquals(204, $deleteResponse['headers']['status-code']);
// Query deleted collection - should fail gracefully
$response2 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $databaseId,
'collectionId' => 'toDelete',
],
]);
$this->assertEquals(200, $response2['headers']['status-code']);
// Should have error since collection no longer exists
$this->assertArrayHasKey('errors', $response2['body']);
}
/**
* Test schema correctly handles multiple collections.
*/
public function testSchemaWithMultipleCollections(): void
{
$projectId = $this->getProject()['$id'];
// Create a database
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'databaseId' => ID::unique(),
'name' => 'Multi Collection DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create multiple collections
$collections = ['users', 'posts', 'comments'];
foreach ($collections as $collectionName) {
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'collectionId' => $collectionName,
'name' => ucfirst($collectionName),
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Add a unique attribute to each
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionName . '/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'key' => $collectionName . 'Field',
'size' => 100,
'required' => false,
]);
}
// Wait for all attributes in all collections
foreach ($collections as $collectionName) {
$this->waitForAttributes($databaseId, $collectionName);
}
// Query each collection - all should work
$query = $this->getQuery(self::GET_DOCUMENTS);
foreach ($collections as $collectionName) {
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $this->getHeaders()), [
'query' => $query,
'variables' => [
'databaseId' => $databaseId,
'collectionId' => $collectionName,
],
]);
$this->assertEquals(200, $response['headers']['status-code'], "Failed for collection: $collectionName");
$this->assertArrayNotHasKey('errors', $response['body'], "Errors for collection: $collectionName");
}
}
/**
* Helper to wait for TablesDB table columns to be available.
*/
protected function waitForColumns(string $databaseId, string $tableId, ?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, $tableId, $projectId, $headers) {
$table = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
$this->assertEquals(200, $table['headers']['status-code']);
foreach ($table['body']['columns'] ?? [] as $column) {
$this->assertEquals('available', $column['status'], 'Column ' . $column['key'] . ' not available');
}
}, $timeoutMs, $waitMs);
}
/**
* Test TablesDB dynamic schema - create table with columns and query via GraphQL.
* Uses REST API for setup to ensure hooks run properly.
* Uses a fresh project to avoid cache interference from other tests due to Swoole worker isolation.
*/
public function testTablesDBDynamicSchema(): array
{
// Use a fresh project to ensure clean schema cache (Swoole workers have isolated caches)
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$apiKey = $freshProject['apiKey'];
$headers = ['x-appwrite-key' => $apiKey];
// Create a TablesDB database using REST API
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Dynamic Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
// Create a table using REST API
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'tableId' => 'actors',
'name' => 'Actors',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
Permission::update(Role::users()),
Permission::delete(Role::users()),
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$tableId = $table['body']['$id'];
// Create columns using REST API
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'name',
'size' => 256,
'required' => true,
]);
$this->assertEquals(202, $column['headers']['status-code']);
$column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'age',
'min' => 0,
'max' => 150,
'required' => false,
]);
$this->assertEquals(202, $column['headers']['status-code']);
// Wait for columns to be available
$this->waitForColumns($databaseId, $tableId, $projectId, $apiKey);
// Debug: Check database type
$dbResp = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
$this->assertEquals(200, $dbResp['headers']['status-code'], 'Database query failed');
// Debug: List tables
$tableResp = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $tableId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
$this->assertEquals(200, $tableResp['headers']['status-code'], 'Table query failed');
// Create a row using REST API
$row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'rowId' => ID::unique(),
'data' => [
'name' => 'John Doe',
'age' => 30,
],
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $row['headers']['status-code']);
$rowId = $row['body']['$id'];
// Query using the dynamic schema - actorsList
$dynamicQuery = 'query { actorsList { _id name age } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'query' => $dynamicQuery,
]);
$this->assertEquals(200, $response['headers']['status-code']);
if (isset($response['body']['errors'])) {
$this->fail('GraphQL errors: ' . json_encode($response['body']['errors']));
}
$this->assertArrayNotHasKey('errors', $response['body']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayHasKey('actorsList', $response['body']['data']);
$actors = $response['body']['data']['actorsList'];
$this->assertNotEmpty($actors);
$this->assertEquals('John Doe', $actors[0]['name']);
$this->assertEquals(30, $actors[0]['age']);
return [
'projectId' => $projectId,
'apiKey' => $apiKey,
'databaseId' => $databaseId,
'tableId' => $tableId,
'rowId' => $rowId,
];
}
/**
* @depends testTablesDBDynamicSchema
* Test TablesDB dynamic schema - get single row via GraphQL.
*/
public function testTablesDBDynamicSchemaGet(array $data): void
{
$projectId = $data['projectId'];
$headers = ['x-appwrite-key' => $data['apiKey']];
// Query using the dynamic schema - actorsGet
$dynamicQuery = 'query actorsGet($id: String!) { actorsGet(id: $id) { _id name age } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'query' => $dynamicQuery,
'variables' => [
'id' => $data['rowId'],
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response['body']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayHasKey('actorsGet', $response['body']['data']);
$actor = $response['body']['data']['actorsGet'];
$this->assertEquals('John Doe', $actor['name']);
$this->assertEquals(30, $actor['age']);
}
/**
* @depends testTablesDBDynamicSchema
* Test TablesDB dynamic schema - create row via GraphQL mutation.
*/
public function testTablesDBDynamicSchemaCreate(array $data): void
{
$projectId = $data['projectId'];
$headers = ['x-appwrite-key' => $data['apiKey']];
// Create using the dynamic schema - actorsCreate
$dynamicQuery = 'mutation actorsCreate($name: String!, $age: Int) { actorsCreate(name: $name, age: $age) { _id name age } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'query' => $dynamicQuery,
'variables' => [
'name' => 'Jane Smith',
'age' => 25,
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response['body']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayHasKey('actorsCreate', $response['body']['data']);
$actor = $response['body']['data']['actorsCreate'];
$this->assertEquals('Jane Smith', $actor['name']);
$this->assertEquals(25, $actor['age']);
$this->assertNotEmpty($actor['_id']);
}
/**
* @depends testTablesDBDynamicSchema
* Test TablesDB dynamic schema - update row via GraphQL mutation.
*/
public function testTablesDBDynamicSchemaUpdate(array $data): void
{
$projectId = $data['projectId'];
$headers = ['x-appwrite-key' => $data['apiKey']];
// Update using the dynamic schema - actorsUpdate
$dynamicQuery = 'mutation actorsUpdate($id: String!, $name: String, $age: Int) { actorsUpdate(id: $id, name: $name, age: $age) { _id name age } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'query' => $dynamicQuery,
'variables' => [
'id' => $data['rowId'],
'name' => 'John Updated',
'age' => 35,
],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response['body']);
$this->assertArrayHasKey('data', $response['body']);
$this->assertArrayHasKey('actorsUpdate', $response['body']['data']);
$actor = $response['body']['data']['actorsUpdate'];
$this->assertEquals('John Updated', $actor['name']);
$this->assertEquals(35, $actor['age']);
}
/**
* Test that schema is rebuilt when a 'schema' group route is called.
* Verifies that adding an attribute makes the new field available in GraphQL.
*/
public function testSchemaRebuildsOnSchemaGroupRoute(): void
{
// Use a fresh project to ensure clean state
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Schema Rebuild Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'rebuildTest',
'name' => 'Rebuild Test',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Create first attribute
$attr1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/rebuildTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'field1',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr1['headers']['status-code']);
$this->waitForAttributes($databaseId, 'rebuildTest', $projectId, $freshProject['apiKey']);
// Create a document
$doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/rebuildTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['field1' => 'value1'],
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $doc['headers']['status-code']);
// Query via GraphQL - should work with field1
$query1 = 'query { rebuildTestList { _id field1 } }';
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query1]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
$this->assertEquals('value1', $response1['body']['data']['rebuildTestList'][0]['field1']);
// Now add a second attribute (this is a 'schema' group route)
$attr2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/rebuildTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'field2',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr2['headers']['status-code']);
$this->waitForAttributes($databaseId, 'rebuildTest', $projectId, $freshProject['apiKey']);
// Update document with new field
$this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/rebuildTest/documents/' . $doc['body']['$id'], array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'data' => ['field2' => 'value2'],
]);
// Query via GraphQL - schema should be rebuilt and include field2
$query2 = 'query { rebuildTestList { _id field1 field2 } }';
$response2 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query2]);
$this->assertEquals(200, $response2['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response2['body'], 'Schema should have been rebuilt to include field2');
$this->assertEquals('value1', $response2['body']['data']['rebuildTestList'][0]['field1']);
$this->assertEquals('value2', $response2['body']['data']['rebuildTestList'][0]['field2']);
}
/**
* Test that schema is NOT rebuilt when non-'schema' group routes are called.
* Document CRUD operations should not invalidate the schema cache.
*/
public function testSchemaNotRebuiltOnNonSchemaGroupRoute(): void
{
// Use a fresh project to ensure clean state
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection with attribute
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Non-Schema Route Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'nonSchemaTest',
'name' => 'Non-Schema Test',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
Permission::update(Role::users()),
Permission::delete(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$attr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/nonSchemaTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'title',
'size' => 100,
'required' => true,
]);
$this->assertEquals(202, $attr['headers']['status-code']);
$this->waitForAttributes($databaseId, 'nonSchemaTest', $projectId, $freshProject['apiKey']);
// Query via GraphQL to build/cache the schema
$query = 'query { nonSchemaTestList { _id title } }';
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
// Perform multiple document CRUD operations (non-'schema' group routes)
// These should NOT invalidate the schema cache
// Create document
$doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/nonSchemaTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['title' => 'Test Document'],
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $doc['headers']['status-code']);
$docId = $doc['body']['$id'];
// Update document
$this->client->call(Client::METHOD_PATCH, '/databases/' . $databaseId . '/collections/nonSchemaTest/documents/' . $docId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'data' => ['title' => 'Updated Document'],
]);
// Query via GraphQL multiple times - schema should still work (cached)
for ($i = 0; $i < 5; $i++) {
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response['headers']['status-code'], "Request $i failed");
$this->assertArrayNotHasKey('errors', $response['body'], "Request $i has errors - schema may have been incorrectly invalidated");
}
// Delete document (also non-'schema' group)
$this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/nonSchemaTest/documents/' . $docId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
// GraphQL should still work
$response3 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response3['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response3['body']);
}
/**
* Test that database update (non-schema route after our fix) doesn't invalidate schema.
*/
public function testDatabaseUpdateDoesNotInvalidateSchema(): void
{
// Use a fresh project
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection with attribute
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'DB Update Test',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'dbUpdateTest',
'name' => 'DB Update Test Collection',
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$attr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/dbUpdateTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'name',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr['headers']['status-code']);
$this->waitForAttributes($databaseId, 'dbUpdateTest', $projectId, $freshProject['apiKey']);
// Create a document
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/dbUpdateTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['name' => 'Test'],
'permissions' => [Permission::read(Role::any())],
]);
// Query via GraphQL to cache the schema
$query = 'query { dbUpdateTestList { _id name } }';
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
// Update database name (should NOT invalidate schema after our fix)
$updateDb = $this->client->call(Client::METHOD_PUT, '/databases/' . $databaseId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'name' => 'Updated DB Name',
]);
$this->assertEquals(200, $updateDb['headers']['status-code']);
// Query via GraphQL - should still work without rebuild
$response2 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response2['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response2['body'], 'Schema should still be cached after database name update');
}
/**
* Test concurrent GraphQL requests don't cause schema rebuild issues.
* Sends multiple parallel requests to ensure schema caching works under concurrency.
*/
public function testConcurrentSchemaAccess(): void
{
// Use a fresh project
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection with attribute
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Concurrent Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'concurrentTest',
'name' => 'Concurrent Test',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$attr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/concurrentTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'value',
'size' => 100,
'required' => true,
]);
$this->assertEquals(202, $attr['headers']['status-code']);
$this->waitForAttributes($databaseId, 'concurrentTest', $projectId, $freshProject['apiKey']);
// Create multiple documents
for ($i = 0; $i < 5; $i++) {
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/concurrentTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['value' => 'item' . $i],
'permissions' => [Permission::read(Role::any())],
]);
}
// Send multiple concurrent requests using curl_multi
$query = 'query { concurrentTestList { _id value } }';
$numRequests = 20;
$multiHandle = curl_multi_init();
$curlHandles = [];
$endpoint = $this->client->getEndpoint();
for ($i = 0; $i < $numRequests; $i++) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $endpoint . '/graphql');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['query' => $query]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Appwrite-Project: ' . $projectId,
'X-Appwrite-Key: ' . $freshProject['apiKey'],
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_multi_add_handle($multiHandle, $ch);
$curlHandles[] = $ch;
}
// Execute all requests concurrently
$running = null;
do {
curl_multi_exec($multiHandle, $running);
curl_multi_select($multiHandle);
} while ($running > 0);
// Collect responses
$responses = [];
foreach ($curlHandles as $ch) {
$response = curl_multi_getcontent($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$responses[] = [
'code' => $httpCode,
'body' => json_decode($response, true),
];
curl_multi_remove_handle($multiHandle, $ch);
curl_close($ch);
}
curl_multi_close($multiHandle);
// Verify all requests succeeded
foreach ($responses as $i => $response) {
$this->assertEquals(200, $response['code'], "Concurrent request $i failed with HTTP " . $response['code']);
$this->assertArrayHasKey('data', $response['body'], "Concurrent request $i missing data");
$this->assertArrayNotHasKey('errors', $response['body'], "Concurrent request $i has errors: " . json_encode($response['body']['errors'] ?? []));
$this->assertArrayHasKey('concurrentTestList', $response['body']['data'], "Concurrent request $i missing concurrentTestList");
$this->assertCount(5, $response['body']['data']['concurrentTestList'], "Concurrent request $i returned wrong document count");
}
}
/**
* Test concurrent schema rebuilds don't cause issues.
* Triggers schema invalidation while concurrent requests are in flight.
*/
public function testConcurrentSchemaRebuilds(): void
{
// Use a fresh project
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Concurrent Rebuild DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'rebuildRace',
'name' => 'Rebuild Race Test',
'permissions' => [
Permission::read(Role::any()),
Permission::create(Role::users()),
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Create initial attribute
$attr = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/rebuildRace/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'field1',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr['headers']['status-code']);
$this->waitForAttributes($databaseId, 'rebuildRace', $projectId, $freshProject['apiKey']);
// Create a document
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/rebuildRace/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['field1' => 'test'],
'permissions' => [Permission::read(Role::any())],
]);
// Initial query to cache schema
$query = 'query { rebuildRaceList { _id field1 } }';
$response = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query]);
$this->assertEquals(200, $response['headers']['status-code']);
// Now trigger multiple schema-modifying operations in quick succession
// while also sending GraphQL queries
$endpoint = $this->client->getEndpoint();
$multiHandle = curl_multi_init();
$curlHandles = [];
// Add several GraphQL query requests
for ($i = 0; $i < 10; $i++) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $endpoint . '/graphql');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['query' => $query]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Appwrite-Project: ' . $projectId,
'X-Appwrite-Key: ' . $freshProject['apiKey'],
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_multi_add_handle($multiHandle, $ch);
$curlHandles['query_' . $i] = $ch;
}
// Add schema-modifying requests (create new attributes)
for ($i = 0; $i < 3; $i++) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $endpoint . '/databases/' . $databaseId . '/collections/rebuildRace/attributes/string');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'key' => 'concurrent' . $i,
'size' => 100,
'required' => false,
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Appwrite-Project: ' . $projectId,
'X-Appwrite-Key: ' . $freshProject['apiKey'],
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_multi_add_handle($multiHandle, $ch);
$curlHandles['attr_' . $i] = $ch;
}
// Execute all concurrently
$running = null;
do {
curl_multi_exec($multiHandle, $running);
curl_multi_select($multiHandle);
} while ($running > 0);
// Collect responses
$queryResponses = [];
$attrResponses = [];
foreach ($curlHandles as $key => $ch) {
$response = curl_multi_getcontent($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (str_starts_with($key, 'query_')) {
$queryResponses[] = [
'code' => $httpCode,
'body' => json_decode($response, true),
];
} else {
$attrResponses[] = [
'code' => $httpCode,
'body' => json_decode($response, true),
];
}
curl_multi_remove_handle($multiHandle, $ch);
curl_close($ch);
}
curl_multi_close($multiHandle);
// All GraphQL queries should succeed (may see different schema versions, but no errors)
$successCount = 0;
foreach ($queryResponses as $i => $response) {
// We accept 200 responses - the schema might be in various states during rebuild
if ($response['code'] === 200 && isset($response['body']['data'])) {
$successCount++;
}
}
// At least most queries should succeed
$this->assertGreaterThanOrEqual(7, $successCount, "Expected at least 7/10 queries to succeed during concurrent rebuilds, got $successCount");
// Wait for attributes to be available
$this->waitForAttributes($databaseId, 'rebuildRace', $projectId, $freshProject['apiKey']);
// Final query should include all fields
$finalQuery = 'query { rebuildRaceList { _id field1 } }';
$finalResponse = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $finalQuery]);
$this->assertEquals(200, $finalResponse['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $finalResponse['body']);
}
/**
* Test that attribute deletion triggers schema rebuild.
*/
public function testSchemaRebuildsOnAttributeDelete(): void
{
// Use a fresh project
$freshProject = $this->getProject(true);
$projectId = $freshProject['$id'];
$headers = ['x-appwrite-key' => $freshProject['apiKey']];
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'databaseId' => ID::unique(),
'name' => 'Attr Delete Test DB',
]);
$this->assertEquals(201, $database['headers']['status-code']);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'collectionId' => 'attrDeleteTest',
'name' => 'Attr Delete Test',
'permissions' => [Permission::read(Role::any())],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
// Create two attributes
$attr1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/attrDeleteTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'keepMe',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr1['headers']['status-code']);
$attr2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/attrDeleteTest/attributes/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'key' => 'deleteMe',
'size' => 100,
'required' => false,
]);
$this->assertEquals(202, $attr2['headers']['status-code']);
$this->waitForAttributes($databaseId, 'attrDeleteTest', $projectId, $freshProject['apiKey']);
// Create document
$this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/attrDeleteTest/documents', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), [
'documentId' => ID::unique(),
'data' => ['keepMe' => 'kept', 'deleteMe' => 'deleted'],
'permissions' => [Permission::read(Role::any())],
]);
// Query with both fields
$query1 = 'query { attrDeleteTestList { _id keepMe deleteMe } }';
$response1 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query1]);
$this->assertEquals(200, $response1['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response1['body']);
// Delete the attribute
$delete = $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId . '/collections/attrDeleteTest/attributes/deleteMe', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers));
$this->assertEquals(204, $delete['headers']['status-code']);
// Wait for attribute deletion to complete
sleep(2);
// Query with deleted field should fail
$response2 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query1]);
$this->assertEquals(200, $response2['headers']['status-code']);
$this->assertArrayHasKey('errors', $response2['body'], 'Query with deleted field should error after schema rebuild');
// Query without deleted field should work
$query2 = 'query { attrDeleteTestList { _id keepMe } }';
$response3 = $this->client->call(Client::METHOD_POST, '/graphql', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
], $headers), ['query' => $query2]);
$this->assertEquals(200, $response3['headers']['status-code']);
$this->assertArrayNotHasKey('errors', $response3['body']);
$this->assertEquals('kept', $response3['body']['data']['attrDeleteTestList'][0]['keepMe']);
}
}