Merge pull request #10626 from appwrite/feat-txn

Fix cross API compatibility
This commit is contained in:
Jake Barnby 2025-10-10 18:38:42 +13:00 committed by GitHub
commit 821b3b86e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 497 additions and 33 deletions

View file

@ -205,6 +205,31 @@ abstract class Action extends AppwriteAction
return $this->isCollectionsAPI() ? 'collection' : 'table';
}
/**
* Get the correct attribute/column key for increment/decrement operations.
*/
protected function getAttributeKey(): string
{
return $this->isCollectionsAPI() ? 'attribute' : 'column';
}
/**
* Get the key used in ID parameters (e.g., 'collectionId' or 'tableId').
*/
protected function getGroupId(): string
{
return $this->getCollectionsEventsContext() . 'Id';
}
/**
* Get the resource ID key for the current action.
*/
protected function getResourceId(): string
{
$resource = $this->isCollectionsAPI() ? 'document' : 'row';
return $resource . 'Id';
}
/**
* Remove configured removable attributes from a document.
* Used for relationship path handling to remove API-specific attributes.

View file

@ -136,7 +136,7 @@ class Decrement extends Action
'documentId' => $documentId,
'action' => 'decrement',
'data' => [
'attribute' => $attribute,
$this->getAttributeKey() => $attribute,
'value' => $value,
'min' => $min,
],
@ -153,9 +153,10 @@ class Decrement extends Action
});
// Return successful response without actually decrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
$attribute => $value,
]);

View file

@ -136,7 +136,7 @@ class Increment extends Action
'documentId' => $documentId,
'action' => 'increment',
'data' => [
'attribute' => $attribute,
$this->getAttributeKey() => $attribute,
'value' => $value,
'max' => $max,
],
@ -153,9 +153,10 @@ class Increment extends Action
});
// Return successful response without actually incrementing
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
$attribute => $value,
]);

View file

@ -419,9 +419,10 @@ class Create extends Action
'total' => \count($documents),
]), $this->getBulkResponseModel());
} else {
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documents[0]['$id'] ?? $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$documents[0]
]);

View file

@ -292,9 +292,10 @@ class Update extends Action
});
// Return successful response without actually updating document
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$document->getArrayCopy(),
...$data

View file

@ -301,9 +301,10 @@ class Upsert extends Action
});
// Return successful response without actually upserting document
$groupId = $this->getGroupId();
$mockDocument = new Document([
'$id' => $documentId,
'$collectionId' => $collectionId,
'$' . $groupId => $collectionId,
'$databaseId' => $databaseId,
...$data
]);

View file

@ -167,8 +167,10 @@ class Update extends Action
}
}
$totalOperations++;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + 1;
if (!\in_array($action, ['bulkCreate', 'bulkUpdate', 'bulkUpsert', 'bulkDelete'])) {
$totalOperations++;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + 1;
}
if ($data instanceof Document) {
$data = $data->getArrayCopy();
@ -194,16 +196,24 @@ class Update extends Action
$this->handleDecrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state);
break;
case 'bulkCreate':
$this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpdate':
$this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkUpsert':
$this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
case 'bulkDelete':
$this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$count = $this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state);
$totalOperations += $count;
$databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count;
break;
}
}
@ -547,6 +557,28 @@ class Update extends Action
});
}
/**
* Get the attribute/column name from data, with fallback for cross-API compatibility
*
* @param array $data The operation data
* @return string The attribute/column name
*/
private function getAttributeNameFromData(array $data): string
{
$expectedKey = $this->getAttributeKey();
if (isset($data[$expectedKey])) {
return $data[$expectedKey];
}
// Try the opposite key for cross-API compatibility
$fallbackKey = $expectedKey === 'attribute' ? 'column' : 'attribute';
if (isset($data[$fallbackKey])) {
return $data[$fallbackKey];
}
return '';
}
/**
* Handle increment operation
*
@ -569,23 +601,24 @@ class Update extends Action
array &$state
): void {
$dependent = isset($state[$collectionId][$documentId]);
$attribute = $this->getAttributeNameFromData($data);
if ($dependent) {
$state[$collectionId][$documentId] = $dbForProject->increaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
max: $data['max'] ?? null
);
return;
}
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state) {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state, $attribute) {
$state[$collectionId][$documentId] = $dbForProject->increaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
max: $data['max'] ?? null
);
@ -614,23 +647,24 @@ class Update extends Action
array &$state
): void {
$dependent = isset($state[$collectionId][$documentId]);
$attribute = $this->getAttributeNameFromData($data);
if ($dependent) {
$state[$collectionId][$documentId] = $dbForProject->decreaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
min: $data['min'] ?? null
);
return;
}
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state) {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $documentId, $data, &$state, $attribute) {
$state[$collectionId][$documentId] = $dbForProject->decreaseDocumentAttribute(
collection: $collectionId,
id: $documentId,
attribute: $data[$this->getAttributeKey()],
attribute: $attribute,
value: $data['value'] ?? 1,
min: $data['min'] ?? null
);
@ -645,7 +679,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents created
* @throws \Utopia\Database\Exception
*/
private function handleBulkCreateOperation(
@ -654,13 +688,14 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $data, &$state) {
): int {
$count = 0;
$dbForProject->withRequestTimestamp($createdAt, function () use ($dbForProject, $collectionId, $data, &$state, &$count) {
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
}, $data);
$dbForProject->createDocuments(
$count = $dbForProject->createDocuments(
$collectionId,
$documents,
onNext: function (Document $document) use (&$state, $collectionId) {
@ -668,6 +703,7 @@ class Update extends Action
}
);
});
return $count;
}
/**
@ -679,7 +715,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents updated
* @throws \Utopia\Database\Exception
* @throws \Utopia\Database\Exception\Query
* @throws ConflictException
@ -691,7 +727,7 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$queries = Query::parseQueries($data['queries'] ?? []);
$updateData = new Document($data['data']);
@ -701,7 +737,7 @@ class Update extends Action
// Clone the document before passing to updateDocuments to prevent mutation
// The database layer mutates the input document, which would corrupt transaction state
$dbForProject->updateDocuments(
$count = $dbForProject->updateDocuments(
$collectionId,
clone $updateData,
$queries,
@ -739,6 +775,8 @@ class Update extends Action
);
}
}
return $count;
}
/**
@ -750,7 +788,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents upserted
* @throws ConflictException
* @throws \Utopia\Database\Exception
*/
@ -761,14 +799,14 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$documents = \array_map(function ($doc) {
return $doc instanceof Document ? $doc : new Document($doc);
}, $data);
$mergedDocuments = $transactionState->applyBulkUpsertToState($collectionId, $documents, $state);
$dbForProject->upsertDocuments(
$count = $dbForProject->upsertDocuments(
$collectionId,
$mergedDocuments,
onNext: function (Document $upserted, ?Document $old) use (&$state, $collectionId, $createdAt) {
@ -786,6 +824,8 @@ class Update extends Action
$state[$collectionId][$upserted->getId()] = $upserted;
}
);
return $count;
}
/**
@ -797,7 +837,7 @@ class Update extends Action
* @param array $data
* @param \DateTime $createdAt
* @param array &$state
* @return void
* @return int Number of documents deleted
* @throws \Utopia\Database\Exception\Query
* @throws ConflictException
* @throws \Utopia\Database\Exception
@ -809,10 +849,10 @@ class Update extends Action
array $data,
\DateTime $createdAt,
array &$state
): void {
): int {
$queries = Query::parseQueries($data['queries'] ?? []);
$dbForProject->deleteDocuments(
$count = $dbForProject->deleteDocuments(
$collectionId,
$queries,
onNext: function (Document $deleted, Document $old) use (&$state, $collectionId, $createdAt) {
@ -832,5 +872,7 @@ class Update extends Action
);
$transactionState->applyBulkDeleteToState($collectionId, $queries, $state);
return $count;
}
}

View file

@ -82,7 +82,10 @@ class Document extends Any
{
$document->removeAttribute('$collection');
$document->removeAttribute('$tenant');
$document->setAttribute('$sequence', (int)$document->getAttribute('$sequence', 0));
if (!$document->isEmpty()) {
$document->setAttribute('$sequence', (int)$document->getAttribute('$sequence', 0));
}
foreach ($document->getAttributes() as $attribute) {
if (\is_array($attribute)) {

View file

@ -3732,6 +3732,149 @@ trait TransactionsBase
$this->assertEquals(0, $doc['body']['score']);
}
/**
* Test individual increment/decrement endpoints with transactions for Legacy Collections API
* This test ensures that:
* 1. Transaction logs store the correct attribute key ('attribute' for Collections API)
* 2. Mock responses return the correct ID keys ('$collectionId' not '$tableId')
*/
public function testIncrementDecrementEndpointsWithTransaction(): void
{
// Create database and collection
$database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'IncrDecrEndpointTestDB'
]);
$databaseId = $database['body']['$id'];
$collection = $this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'AccountsCollection',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$collectionId = $collection['body']['$id'];
// Add balance attribute
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/attributes/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial documents
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'documentId' => 'joe',
'data' => ['balance' => 100]
]);
$this->client->call(Client::METHOD_POST, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'documentId' => 'jane',
'data' => ['balance' => 50]
]);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/databases/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(201, $transaction['headers']['status-code']);
$transactionId = $transaction['body']['$id'];
// Test: Decrement using individual endpoint - should store 'attribute' not 'column' in transaction log
$decrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/{$databaseId}/collections/{$collectionId}/documents/joe/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
'min' => 0,
]
);
// Test: Response should return '$collectionId' not '$tableId' for Collections API
$this->assertEquals(200, $decrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$collectionId', $decrementResponse['body'], 'Response should contain $collectionId for Collections API');
$this->assertArrayNotHasKey('$tableId', $decrementResponse['body'], 'Response should not contain $tableId for Collections API');
$this->assertEquals($collectionId, $decrementResponse['body']['$collectionId']);
// Test increment endpoint
$incrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/{$databaseId}/collections/{$collectionId}/documents/jane/balance/increment",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
]
);
$this->assertEquals(200, $incrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$collectionId', $incrementResponse['body'], 'Response should contain $collectionId for Collections API');
$this->assertArrayNotHasKey('$tableId', $incrementResponse['body'], 'Response should not contain $tableId for Collections API');
$this->assertEquals($collectionId, $incrementResponse['body']['$collectionId']);
// Commit transaction - this will fail if transaction log has 'column' instead of 'attribute'
$commitResponse = $this->client->call(Client::METHOD_PATCH, "/databases/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'commit' => true
]);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Transaction commit should succeed');
// Verify final values
$joe = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/joe", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$jane = $this->client->call(Client::METHOD_GET, "/databases/{$databaseId}/collections/{$collectionId}/documents/jane", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $joe['headers']['status-code']);
$this->assertEquals(50, $joe['body']['balance'], 'Joe should have 100 - 50 = 50');
$this->assertEquals(200, $jane['headers']['status-code']);
$this->assertEquals(100, $jane['body']['balance'], 'Jane should have 50 + 50 = 100');
}
/**
* Test bulk update operations in transaction
*/

View file

@ -3867,6 +3867,252 @@ trait TransactionsBase
$this->assertEquals('updated', $row['body']['status'], 'Status should be updated');
}
/**
* Test individual increment/decrement endpoints with transactions
* This test ensures that:
* 1. Transaction logs store the correct attribute key ('column' for TablesDB)
* 2. Mock responses return the correct ID keys ('$tableId' not '$collectionId')
*/
public function testIncrementDecrementEndpointsWithTransaction(): void
{
// Create database and table
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'IncrDecrEndpointTestDB'
]);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'AccountsTable',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$tableId = $table['body']['$id'];
// Add balance column
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial rows
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'joe',
'data' => ['balance' => 100]
]);
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'jane',
'data' => ['balance' => 50]
]);
// Create transaction
$transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(201, $transaction['headers']['status-code']);
$transactionId = $transaction['body']['$id'];
// Test Bug 1: Decrement using individual endpoint - should store 'column' not 'attribute' in transaction log
$decrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/joe/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
'min' => 0,
]
);
// Test Bug 2: Response should return '$tableId' not '$collectionId'
$this->assertEquals(200, $decrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$tableId', $decrementResponse['body'], 'Response should contain $tableId for TablesDB API');
$this->assertArrayNotHasKey('$collectionId', $decrementResponse['body'], 'Response should not contain $collectionId for TablesDB API');
$this->assertEquals($tableId, $decrementResponse['body']['$tableId']);
// Test increment endpoint
$incrementResponse = $this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/jane/balance/increment",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 50,
]
);
$this->assertEquals(200, $incrementResponse['headers']['status-code']);
$this->assertArrayHasKey('$tableId', $incrementResponse['body'], 'Response should contain $tableId for TablesDB API');
$this->assertArrayNotHasKey('$collectionId', $incrementResponse['body'], 'Response should not contain $collectionId for TablesDB API');
$this->assertEquals($tableId, $incrementResponse['body']['$tableId']);
// Commit transaction - this will fail if transaction log has 'attribute' instead of 'column'
$commitResponse = $this->client->call(Client::METHOD_PATCH, "/tablesdb/transactions/{$transactionId}", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'commit' => true
]);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Transaction commit should succeed');
// Verify final values
$joe = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/joe", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$jane = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/jane", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $joe['headers']['status-code']);
$this->assertEquals(50, $joe['body']['balance'], 'Joe should have 100 - 50 = 50');
$this->assertEquals(200, $jane['headers']['status-code']);
$this->assertEquals(100, $jane['body']['balance'], 'Jane should have 50 + 50 = 100');
}
/**
* Test cross-API compatibility: stage operations via TablesDB, commit via Collections API
* This ensures fallback logic works when APIs are mixed
*/
public function testCrossAPIIncrementDecrement(): void
{
// Create database and table
$database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'databaseId' => ID::unique(),
'name' => 'CrossAPITestDB'
]);
$databaseId = $database['body']['$id'];
$table = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'CrossAPITable',
'permissions' => [
Permission::create(Role::any()),
Permission::read(Role::any()),
Permission::update(Role::any()),
],
]);
$tableId = $table['body']['$id'];
// Add balance column
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/columns/integer", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'balance',
'required' => false,
'default' => 0,
]);
sleep(2);
// Create initial row
$this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$tableId}/rows", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'rowId' => 'test',
'data' => ['balance' => 100]
]);
// Create transaction using TablesDB API
$transaction = $this->client->call(Client::METHOD_POST, '/tablesdb/transactions', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$transactionId = $transaction['body']['$id'];
// Stage operations using TablesDB API (will store 'column' key)
$this->client->call(
Client::METHOD_PATCH,
"/tablesdb/{$databaseId}/tables/{$tableId}/rows/test/balance/decrement",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'transactionId' => $transactionId,
'value' => 30,
]
);
// Commit using Collections API (expects 'attribute' key but should fallback to 'column')
$commitResponse = $this->client->call(
Client::METHOD_PATCH,
"/databases/transactions/{$transactionId}",
array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()),
[
'commit' => true
]
);
$this->assertEquals(200, $commitResponse['headers']['status-code'], 'Cross-API commit should succeed');
// Verify final value
$row = $this->client->call(Client::METHOD_GET, "/tablesdb/{$databaseId}/tables/{$tableId}/rows/test", array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $row['headers']['status-code']);
$this->assertEquals(70, $row['body']['balance'], 'Balance should be 100 - 30 = 70');
}
public function testBulkUpdateWithDependentDocuments(): void
{
// Create database and table