From e6239550077089669696886145c469aeb23c4489 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 11:58:23 +0000 Subject: [PATCH 01/18] Fix TypeError committing bulkDelete with array queries Transaction log queries can be SDK JSON strings or already-decoded {method, attribute, values} arrays. Query::parseQueries() always calls parse(string), which TypeErrors on arrays during commit. Parse each stored query with parseQuery() or parse() so valid array queries commit successfully. Apply the same handling when replaying logs in TransactionState. Fixes CLOUD-3QMK Co-authored-by: Chirag Aggarwal --- src/Appwrite/Databases/TransactionState.php | 34 ++++- .../Http/Databases/Transactions/Update.php | 34 ++++- .../Transactions/TransactionsBase.php | 134 ++++++++++++++++++ 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index 8799ef60e56..6ed83865d4f 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -463,7 +463,7 @@ private function getTransactionState(string $transactionId): array case 'bulkUpdate': if (isset($data['queries']) && isset($data['data'])) { - $queries = Query::parseQueries($data['queries'] ?? []); + $queries = $this->parseStoredQueries($data['queries'] ?? []); $updateData = $data['data']; foreach ($state[$collectionId] ?? [] as $docId => $entry) { @@ -520,7 +520,7 @@ private function getTransactionState(string $transactionId): array case 'bulkDelete': if (isset($data['queries'])) { - $queries = Query::parseQueries($data['queries'] ?? []); + $queries = $this->parseStoredQueries($data['queries'] ?? []); $filters = $this->extractFilters($queries); foreach ($state[$collectionId] ?? [] as $docId => $entry) { @@ -738,4 +738,34 @@ private function documentMatchesFilters(Document $doc, array $filters): bool return true; } + + /** + * Parse queries stored on a transaction log. + * + * Each query may be an SDK JSON string or an already-decoded + * `{method, attribute, values}` array. + * + * @param array $queries + * @return array + * @throws Exception\Query + */ + private function parseStoredQueries(array $queries): array + { + $parsed = []; + + foreach ($queries as $query) { + if (\is_array($query)) { + $parsed[] = Query::parseQuery($query); + continue; + } + + if (!\is_string($query)) { + throw new Exception\Query('Invalid query. Must be a string or array, got ' . \gettype($query)); + } + + $parsed[] = Query::parse($query); + } + + return $parsed; + } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index f70c707c5d3..136ae174281 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -860,7 +860,7 @@ private function handleBulkUpdateOperation( \DateTime $createdAt, array &$state ): int { - $queries = Query::parseQueries($data['queries'] ?? []); + $queries = $this->parseStoredQueries($data['queries'] ?? []); $updateData = new Document($data['data']); $dependentDocs = []; @@ -982,7 +982,7 @@ private function handleBulkDeleteOperation( \DateTime $createdAt, array &$state ): int { - $queries = Query::parseQueries($data['queries'] ?? []); + $queries = $this->parseStoredQueries($data['queries'] ?? []); $count = $dbForProject->deleteDocuments( $collectionId, @@ -1007,4 +1007,34 @@ private function handleBulkDeleteOperation( return $count; } + + /** + * Parse queries stored on a transaction log. + * + * Each query may be an SDK JSON string or an already-decoded + * `{method, attribute, values}` array. + * + * @param array $queries + * @return array + * @throws QueryException + */ + private function parseStoredQueries(array $queries): array + { + $parsed = []; + + foreach ($queries as $query) { + if (\is_array($query)) { + $parsed[] = Query::parseQuery($query); + continue; + } + + if (!\is_string($query)) { + throw new QueryException('Invalid query. Must be a string or array, got ' . \gettype($query)); + } + + $parsed[] = Query::parse($query); + } + + return $parsed; + } } diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index a49b9cf25bf..b15636f8c2f 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -2668,6 +2668,140 @@ public function testBulkDelete(): void $this->assertEquals(0, $response['body']['total']); } + /** + * Commit bulkUpdate/bulkDelete with SDK query strings and decoded query arrays. + */ + public function testBulkDeleteAndUpdateWithArrayQueries(): void + { + $database = $this->client->call(Client::METHOD_POST, $this->getDatabaseUrl(), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkArrayQueriesTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'TestCollection', + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "string", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'category', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } + + foreach (['keep_1', 'keep_2', 'drop_1', 'drop_2'] as $id) { + $category = \str_starts_with($id, 'keep') ? 'keep' : 'drop'; + $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + $this->getRecordIdParam() => $id, + 'data' => [ + 'name' => $id, + 'category' => $category, + ] + ]); + } + + $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), 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']; + + $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + $this->getContainerIdParam() => $collectionId, + 'action' => 'bulkUpdate', + 'data' => [ + 'queries' => [Query::equal('category', ['keep'])->toArray()], + 'data' => ['name' => 'updated'], + ], + ], + [ + 'databaseId' => $databaseId, + $this->getContainerIdParam() => $collectionId, + 'action' => 'bulkDelete', + 'data' => [ + 'queries' => [ + Query::equal('category', ['drop'])->toString(), + Query::equal('name', ['drop_1', 'drop_2'])->toArray(), + ], + ], + ], + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_PATCH, $this->getTransactionUrl($transactionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code'], \json_encode($response['body'])); + + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + + foreach ($response['body'][$this->getRecordResource()] as $doc) { + $this->assertEquals('keep', $doc['category']); + $this->assertEquals('updated', $doc['name']); + } + } + /** * Test multiple single route operations in one transaction */ From c7ad4a7fc0f81d117ec7af369bf39c6317ff1323 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 14:41:37 +0100 Subject: [PATCH 02/18] fix(spec): emit FCM service account JSON as an object, not a string The OpenAPI3 parameter switch matches on the concrete validator class, so Utopia\Validator\JSON\FCM fell through to the string default even though it reports TYPE_OBJECT and the controller still accepts an array, stdClass or JSON string. Swapping the FCM param's JSON validator for the stricter FCM one was meant to tighten validation, but it silently narrowed the published type and turned serviceAccountJSON into a string in every generated SDK. --- .../SDK/Specification/Format/OpenAPI3.php | 1 + tests/unit/SDK/Specification/FormatTest.php | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 1ee1babc968..53151e610c1 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -733,6 +733,7 @@ public function parse(): array break; case \Utopia\Validator\JSON::class: case \Utopia\Validator\JSON\ObjectValidator::class: + case \Utopia\Validator\JSON\FCM::class: case \Utopia\Validator\Assoc::class: $node['schema']['type'] = 'object'; $node['schema']['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index 96d05875cf2..5601a70c1de 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -63,6 +63,7 @@ use Utopia\Validator\HexColor; use Utopia\Validator\Integer as IntegerValidator; use Utopia\Validator\JSON; +use Utopia\Validator\JSON\FCM; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -1040,6 +1041,31 @@ public function testAdditionalParametersAreIncludedInRequestBody(): void $this->assertSame('object', $openApiQuery['type']); } + public function testObjectValidatorsOutsideTheSwitchAreNotEmittedAsStrings(): void + { + Method::$processed = []; + Method::$errors = []; + + $route = (new Route('POST', '/v1/tests/fcm')) + ->desc('Create FCM provider') + ->label('sdk', new Method( + namespace: 'test', + group: null, + name: 'createFcmProvider', + description: 'Create FCM provider.', + auth: [], + responses: [], + )) + ->param('serviceAccountJSON', null, new Nullable(new FCM()), 'FCM service account JSON.', true) + ->param('bareServiceAccountJSON', null, new FCM(), 'FCM service account JSON.', true); + + $spec = (new OpenAPI3(new Container(), [], [$route], [], [], 0, 'console'))->parse(); + $properties = $spec['paths']['/tests/fcm']['post']['requestBody']['content']['application/json']['schema']['properties']; + + $this->assertSame('object', $properties['serviceAccountJSON']['type']); + $this->assertSame('object', $properties['bareServiceAccountJSON']['type']); + } + public function testJsonAndNullableModelRulesEmitExpectedSchemas(): void { Method::$processed = []; From fc7e0e633db9e6f9776a97ab740ad70d34a6d0ac Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 14:45:01 +0100 Subject: [PATCH 03/18] refactor(spec): honour the declared type in the OpenAPI3 parameter default Rather than adding one more class to the object case, the default branch now emits an object when the validator reports TYPE_OBJECT, so the next object validator without a case of its own does not regress the same way. The listed cases keep their current behaviour: Assoc reports TYPE_ARRAY but is deliberately published as an object, and a type-driven rule applied to them would change that. Across the full cloud route table this changes exactly the two FCM parameters and nothing else. --- .../SDK/Specification/Format/OpenAPI3.php | 12 +++- tests/unit/SDK/Specification/FormatTest.php | 60 ++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 53151e610c1..44103855452 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -733,7 +733,6 @@ public function parse(): array break; case \Utopia\Validator\JSON::class: case \Utopia\Validator\JSON\ObjectValidator::class: - case \Utopia\Validator\JSON\FCM::class: case \Utopia\Validator\Assoc::class: $node['schema']['type'] = 'object'; $node['schema']['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; @@ -944,6 +943,17 @@ public function parse(): array } break; default: + // The cases above match on the concrete validator class, so a + // validator that declares itself an object but is not listed + // reaches here and would be published as a string. That is how + // JSON\FCM narrowed serviceAccountJSON in every generated SDK. + if ($validator->getType() === Validator::TYPE_OBJECT) { + $node['schema']['type'] = 'object'; + $node['schema']['default'] = empty($param['default']) ? new \stdClass() : $param['default']; + $node['schema']['example'] = ($param['example'] ?? '') !== '' ? $param['example'] : '{}'; + break; + } + $node['schema']['type'] = 'string'; if (($param['example'] ?? '') !== '') { $node['schema']['example'] = $param['example']; diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index 5601a70c1de..c4424d7c0d2 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -54,6 +54,7 @@ use Utopia\DI\Container; use Utopia\Http\Route; use Utopia\Platform\Enum; +use Utopia\Validator; use Utopia\Validator\AnyOf; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; @@ -69,6 +70,56 @@ use Utopia\Validator\Text; use Utopia\Validator\WhiteList; +/** + * Stands in for a validator that reports an object but has no case of its own in + * the OpenAPI3 parameter switch, which is the shape that regressed. + */ +class UnlistedObjectValidator extends Validator +{ + public function getDescription(): string + { + return 'Value must be an object'; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_OBJECT; + } + + public function isValid(mixed $value): bool + { + return \is_array($value) || $value instanceof \stdClass; + } +} + +class UnlistedStringValidator extends Validator +{ + public function getDescription(): string + { + return 'Value must be a string'; + } + + public function isArray(): bool + { + return false; + } + + public function getType(): string + { + return self::TYPE_STRING; + } + + public function isValid(mixed $value): bool + { + return \is_string($value); + } +} + class TestFormat extends Format { public function getName(): string @@ -1041,7 +1092,7 @@ public function testAdditionalParametersAreIncludedInRequestBody(): void $this->assertSame('object', $openApiQuery['type']); } - public function testObjectValidatorsOutsideTheSwitchAreNotEmittedAsStrings(): void + public function testObjectValidatorsWithoutTheirOwnCaseAreNotEmittedAsStrings(): void { Method::$processed = []; Method::$errors = []; @@ -1057,13 +1108,18 @@ public function testObjectValidatorsOutsideTheSwitchAreNotEmittedAsStrings(): vo responses: [], )) ->param('serviceAccountJSON', null, new Nullable(new FCM()), 'FCM service account JSON.', true) - ->param('bareServiceAccountJSON', null, new FCM(), 'FCM service account JSON.', true); + ->param('bareServiceAccountJSON', null, new FCM(), 'FCM service account JSON.', true) + ->param('unlistedObject', null, new UnlistedObjectValidator(), 'An object validator with no case of its own.', true) + ->param('unlistedString', null, new UnlistedStringValidator(), 'A string validator with no case of its own.', true); $spec = (new OpenAPI3(new Container(), [], [$route], [], [], 0, 'console'))->parse(); $properties = $spec['paths']['/tests/fcm']['post']['requestBody']['content']['application/json']['schema']['properties']; $this->assertSame('object', $properties['serviceAccountJSON']['type']); $this->assertSame('object', $properties['bareServiceAccountJSON']['type']); + $this->assertSame('object', $properties['unlistedObject']['type']); + $this->assertEquals(new \stdClass(), $properties['unlistedObject']['default']); + $this->assertSame('string', $properties['unlistedString']['type']); } public function testJsonAndNullableModelRulesEmitExpectedSchemas(): void From d68394c166afe781a0e81e3af470ac5fe23fc786 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 14:45:56 +0100 Subject: [PATCH 04/18] test(spec): drop the OpenAPI3 object-type regression test --- tests/unit/SDK/Specification/FormatTest.php | 82 --------------------- 1 file changed, 82 deletions(-) diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index c4424d7c0d2..96d05875cf2 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -54,7 +54,6 @@ use Utopia\DI\Container; use Utopia\Http\Route; use Utopia\Platform\Enum; -use Utopia\Validator; use Utopia\Validator\AnyOf; use Utopia\Validator\ArrayList; use Utopia\Validator\Assoc; @@ -64,62 +63,11 @@ use Utopia\Validator\HexColor; use Utopia\Validator\Integer as IntegerValidator; use Utopia\Validator\JSON; -use Utopia\Validator\JSON\FCM; use Utopia\Validator\Nullable; use Utopia\Validator\Range; use Utopia\Validator\Text; use Utopia\Validator\WhiteList; -/** - * Stands in for a validator that reports an object but has no case of its own in - * the OpenAPI3 parameter switch, which is the shape that regressed. - */ -class UnlistedObjectValidator extends Validator -{ - public function getDescription(): string - { - return 'Value must be an object'; - } - - public function isArray(): bool - { - return false; - } - - public function getType(): string - { - return self::TYPE_OBJECT; - } - - public function isValid(mixed $value): bool - { - return \is_array($value) || $value instanceof \stdClass; - } -} - -class UnlistedStringValidator extends Validator -{ - public function getDescription(): string - { - return 'Value must be a string'; - } - - public function isArray(): bool - { - return false; - } - - public function getType(): string - { - return self::TYPE_STRING; - } - - public function isValid(mixed $value): bool - { - return \is_string($value); - } -} - class TestFormat extends Format { public function getName(): string @@ -1092,36 +1040,6 @@ public function testAdditionalParametersAreIncludedInRequestBody(): void $this->assertSame('object', $openApiQuery['type']); } - public function testObjectValidatorsWithoutTheirOwnCaseAreNotEmittedAsStrings(): void - { - Method::$processed = []; - Method::$errors = []; - - $route = (new Route('POST', '/v1/tests/fcm')) - ->desc('Create FCM provider') - ->label('sdk', new Method( - namespace: 'test', - group: null, - name: 'createFcmProvider', - description: 'Create FCM provider.', - auth: [], - responses: [], - )) - ->param('serviceAccountJSON', null, new Nullable(new FCM()), 'FCM service account JSON.', true) - ->param('bareServiceAccountJSON', null, new FCM(), 'FCM service account JSON.', true) - ->param('unlistedObject', null, new UnlistedObjectValidator(), 'An object validator with no case of its own.', true) - ->param('unlistedString', null, new UnlistedStringValidator(), 'A string validator with no case of its own.', true); - - $spec = (new OpenAPI3(new Container(), [], [$route], [], [], 0, 'console'))->parse(); - $properties = $spec['paths']['/tests/fcm']['post']['requestBody']['content']['application/json']['schema']['properties']; - - $this->assertSame('object', $properties['serviceAccountJSON']['type']); - $this->assertSame('object', $properties['bareServiceAccountJSON']['type']); - $this->assertSame('object', $properties['unlistedObject']['type']); - $this->assertEquals(new \stdClass(), $properties['unlistedObject']['default']); - $this->assertSame('string', $properties['unlistedString']['type']); - } - public function testJsonAndNullableModelRulesEmitExpectedSchemas(): void { Method::$processed = []; From 0891542ae18040de1d8242751aff6ce25c0b95cb Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 14:47:21 +0100 Subject: [PATCH 05/18] refactor(spec): drop the object cases the parameter default now covers JSON and JSON\ObjectValidator both report TYPE_OBJECT, so the default emits exactly what their cases did. Assoc keeps its case because it reports TYPE_ARRAY and is published as an object on purpose. --- src/Appwrite/SDK/Specification/Format/OpenAPI3.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 44103855452..294e2d72ca7 100644 --- a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php +++ b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php @@ -731,9 +731,9 @@ public function parse(): array $node['schema']['format'] = 'url'; $node['schema']['example'] = ($param['example'] ?? '') !== '' ? $param['example'] : 'https://example.com'; break; - case \Utopia\Validator\JSON::class: - case \Utopia\Validator\JSON\ObjectValidator::class: case \Utopia\Validator\Assoc::class: + // Assoc reports TYPE_ARRAY, so only an explicit case publishes + // it as an object. TYPE_OBJECT is handled by the default. $node['schema']['type'] = 'object'; $node['schema']['default'] = (empty($param['default'])) ? new \stdClass() : $param['default']; $node['schema']['example'] = ($param['example'] ?? '') !== '' ? $param['example'] : '{}'; @@ -943,10 +943,6 @@ public function parse(): array } break; default: - // The cases above match on the concrete validator class, so a - // validator that declares itself an object but is not listed - // reaches here and would be published as a string. That is how - // JSON\FCM narrowed serviceAccountJSON in every generated SDK. if ($validator->getType() === Validator::TYPE_OBJECT) { $node['schema']['type'] = 'object'; $node['schema']['default'] = empty($param['default']) ? new \stdClass() : $param['default']; From d67dd4ea42689eced9f6f5d54b5fe2090309a272 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 19:18:45 +0100 Subject: [PATCH 06/18] Test transaction replay with array queries --- src/Appwrite/Databases/TransactionState.php | 2 +- .../Http/Databases/Transactions/Update.php | 33 ++----------------- .../Transactions/TransactionsBase.php | 18 +++++++++- 3 files changed, 20 insertions(+), 33 deletions(-) diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index 6ed83865d4f..c3082f8e0f2 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -749,7 +749,7 @@ private function documentMatchesFilters(Document $doc, array $filters): bool * @return array * @throws Exception\Query */ - private function parseStoredQueries(array $queries): array + public function parseStoredQueries(array $queries): array { $parsed = []; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 136ae174281..c8efd4680e7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -860,7 +860,7 @@ private function handleBulkUpdateOperation( \DateTime $createdAt, array &$state ): int { - $queries = $this->parseStoredQueries($data['queries'] ?? []); + $queries = $transactionState->parseStoredQueries($data['queries'] ?? []); $updateData = new Document($data['data']); $dependentDocs = []; @@ -982,7 +982,7 @@ private function handleBulkDeleteOperation( \DateTime $createdAt, array &$state ): int { - $queries = $this->parseStoredQueries($data['queries'] ?? []); + $queries = $transactionState->parseStoredQueries($data['queries'] ?? []); $count = $dbForProject->deleteDocuments( $collectionId, @@ -1008,33 +1008,4 @@ private function handleBulkDeleteOperation( return $count; } - /** - * Parse queries stored on a transaction log. - * - * Each query may be an SDK JSON string or an already-decoded - * `{method, attribute, values}` array. - * - * @param array $queries - * @return array - * @throws QueryException - */ - private function parseStoredQueries(array $queries): array - { - $parsed = []; - - foreach ($queries as $query) { - if (\is_array($query)) { - $parsed[] = Query::parseQuery($query); - continue; - } - - if (!\is_string($query)) { - throw new QueryException('Invalid query. Must be a string or array, got ' . \gettype($query)); - } - - $parsed[] = Query::parse($query); - } - - return $parsed; - } } diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index b15636f8c2f..0361cc7031f 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -2671,8 +2671,9 @@ public function testBulkDelete(): void /** * Commit bulkUpdate/bulkDelete with SDK query strings and decoded query arrays. */ - public function testBulkDeleteAndUpdateWithArrayQueries(): void + public function testUpdateBulkOperationsWithArrayQueries(): void { + // Test for SUCCESS $database = $this->client->call(Client::METHOD_POST, $this->getDatabaseUrl(), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -2778,6 +2779,21 @@ public function testBulkDeleteAndUpdateWithArrayQueries(): void $this->assertEquals(201, $response['headers']['status-code']); + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['total']); + + foreach ($response['body'][$this->getRecordResource()] as $doc) { + $this->assertEquals('keep', $doc['category']); + $this->assertEquals('updated', $doc['name']); + } + $response = $this->client->call(Client::METHOD_PATCH, $this->getTransactionUrl($transactionId), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], From 208c47c573c3357078f93ec2d560ff1434c853cf Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 19:34:52 +0100 Subject: [PATCH 07/18] Reject array queries in transaction operations --- .../Utopia/Database/Validator/Operation.php | 6 ++ .../Transactions/TransactionsBase.php | 64 ++++++------------- 2 files changed, 24 insertions(+), 46 deletions(-) diff --git a/src/Appwrite/Utopia/Database/Validator/Operation.php b/src/Appwrite/Utopia/Database/Validator/Operation.php index 6d506117082..6d85fd703ce 100644 --- a/src/Appwrite/Utopia/Database/Validator/Operation.php +++ b/src/Appwrite/Utopia/Database/Validator/Operation.php @@ -177,6 +177,12 @@ public function isValid($value): bool $this->description = "Key 'queries' must be an array for {$action}"; return false; } + foreach ($value['data']['queries'] as $query) { + if (!\is_string($query)) { + $this->description = "Key 'data.queries' must contain only strings for {$action}"; + return false; + } + } } // BulkUpdate requires both queries and data diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index 0361cc7031f..1e68f16ebbb 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -2669,11 +2669,11 @@ public function testBulkDelete(): void } /** - * Commit bulkUpdate/bulkDelete with SDK query strings and decoded query arrays. + * Reject bulkUpdate/bulkDelete with decoded query arrays. */ - public function testUpdateBulkOperationsWithArrayQueries(): void + public function testCreateBulkOperationsWithArrayQueries(): void { - // Test for SUCCESS + // Test for FAILURE $database = $this->client->call(Client::METHOD_POST, $this->getDatabaseUrl(), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -2763,59 +2763,31 @@ public function testUpdateBulkOperationsWithArrayQueries(): void 'data' => ['name' => 'updated'], ], ], - [ - 'databaseId' => $databaseId, - $this->getContainerIdParam() => $collectionId, - 'action' => 'bulkDelete', - 'data' => [ - 'queries' => [ - Query::equal('category', ['drop'])->toString(), - Query::equal('name', ['drop_1', 'drop_2'])->toArray(), - ], - ], - ], ] ]); - $this->assertEquals(201, $response['headers']['status-code']); - - $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'transactionId' => $transactionId - ]); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(2, $response['body']['total']); - - foreach ($response['body'][$this->getRecordResource()] as $doc) { - $this->assertEquals('keep', $doc['category']); - $this->assertEquals('updated', $doc['name']); - } + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('general_argument_invalid', $response['body']['type']); - $response = $this->client->call(Client::METHOD_PATCH, $this->getTransactionUrl($transactionId), array_merge([ + $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] ]), [ - 'commit' => true + 'operations' => [ + [ + 'databaseId' => $databaseId, + $this->getContainerIdParam() => $collectionId, + 'action' => 'bulkDelete', + 'data' => [ + 'queries' => [Query::equal('category', ['drop'])->toArray()], + ], + ], + ] ]); - $this->assertEquals(200, $response['headers']['status-code'], \json_encode($response['body'])); - - $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders())); - - $this->assertEquals(200, $response['headers']['status-code']); - $this->assertEquals(2, $response['body']['total']); - - foreach ($response['body'][$this->getRecordResource()] as $doc) { - $this->assertEquals('keep', $doc['category']); - $this->assertEquals('updated', $doc['name']); - } + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('general_argument_invalid', $response['body']['type']); } /** From 8182b12c9250a7a30662f1d9fd632140295f4ab5 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 31 Aug 2026 20:05:24 +0100 Subject: [PATCH 08/18] Enforce transaction query input at validation --- src/Appwrite/Databases/TransactionState.php | 34 ++----------------- .../Http/Databases/Transactions/Update.php | 5 ++- 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index c3082f8e0f2..8799ef60e56 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -463,7 +463,7 @@ private function getTransactionState(string $transactionId): array case 'bulkUpdate': if (isset($data['queries']) && isset($data['data'])) { - $queries = $this->parseStoredQueries($data['queries'] ?? []); + $queries = Query::parseQueries($data['queries'] ?? []); $updateData = $data['data']; foreach ($state[$collectionId] ?? [] as $docId => $entry) { @@ -520,7 +520,7 @@ private function getTransactionState(string $transactionId): array case 'bulkDelete': if (isset($data['queries'])) { - $queries = $this->parseStoredQueries($data['queries'] ?? []); + $queries = Query::parseQueries($data['queries'] ?? []); $filters = $this->extractFilters($queries); foreach ($state[$collectionId] ?? [] as $docId => $entry) { @@ -738,34 +738,4 @@ private function documentMatchesFilters(Document $doc, array $filters): bool return true; } - - /** - * Parse queries stored on a transaction log. - * - * Each query may be an SDK JSON string or an already-decoded - * `{method, attribute, values}` array. - * - * @param array $queries - * @return array - * @throws Exception\Query - */ - public function parseStoredQueries(array $queries): array - { - $parsed = []; - - foreach ($queries as $query) { - if (\is_array($query)) { - $parsed[] = Query::parseQuery($query); - continue; - } - - if (!\is_string($query)) { - throw new Exception\Query('Invalid query. Must be a string or array, got ' . \gettype($query)); - } - - $parsed[] = Query::parse($query); - } - - return $parsed; - } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index c8efd4680e7..f70c707c5d3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -860,7 +860,7 @@ private function handleBulkUpdateOperation( \DateTime $createdAt, array &$state ): int { - $queries = $transactionState->parseStoredQueries($data['queries'] ?? []); + $queries = Query::parseQueries($data['queries'] ?? []); $updateData = new Document($data['data']); $dependentDocs = []; @@ -982,7 +982,7 @@ private function handleBulkDeleteOperation( \DateTime $createdAt, array &$state ): int { - $queries = $transactionState->parseStoredQueries($data['queries'] ?? []); + $queries = Query::parseQueries($data['queries'] ?? []); $count = $dbForProject->deleteDocuments( $collectionId, @@ -1007,5 +1007,4 @@ private function handleBulkDeleteOperation( return $count; } - } From bc9afc003196c40c8c119a180ac250b9d3e538b0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 1 Sep 2026 11:23:35 +0530 Subject: [PATCH 09/18] feat: make the GitLab endpoint configurable Both GitLab surfaces hardcoded https://gitlab.com, so a self-hosted GitLab could not be used for either repository integration or console sign-in. _APP_VCS_GITLAB_ENDPOINT covers repository integration: the vcs registry entry Vcs\Factory hands to setEndpoint() and to the OAuth2 builder, plus the authorize and callback handlers that build their own client. _APP_CONSOLE_GITLAB_ENDPOINT covers console sign-in. Auth\OAuth2\Gitlab reads its endpoint out of a JSON-encoded secret, so gitlabSecret now carries {"clientSecret","endpoint"} rather than the raw secret string. That also fixes console sign-in outright, which threw "Invalid secret" on the undecodable plain string. The value stays empty when unconfigured so account.php keeps reporting the provider as disabled. Both default to https://gitlab.com. --- .env | 2 ++ app/config/console.php | 8 +++++++- app/config/variables.php | 18 ++++++++++++++++++ app/config/vcs.php | 5 +++-- docker-compose.yml | 4 ++++ src/Appwrite/Auth/OAuth2/Gitlab.php | 5 +++-- .../Modules/VCS/Http/Gitlab/Authorize/Get.php | 2 +- .../Modules/VCS/Http/Gitlab/Callback/Get.php | 2 +- 8 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.env b/.env index 3ff3672e3dc..7fe753006fa 100644 --- a/.env +++ b/.env @@ -41,6 +41,7 @@ _APP_CONSOLE_GITHUB_APP_ID= _APP_CONSOLE_GITHUB_SECRET= _APP_CONSOLE_GITLAB_APP_ID= _APP_CONSOLE_GITLAB_SECRET= +_APP_CONSOLE_GITLAB_ENDPOINT=https://gitlab.com _APP_CONSOLE_BITBUCKET_APP_ID= _APP_CONSOLE_BITBUCKET_SECRET= _APP_CONSOLE_GOOGLE_APP_ID= @@ -181,6 +182,7 @@ _APP_VCS_GITEA_BROWSER_ENDPOINT=http://localhost:9515 _APP_VCS_GITEA_CLIENT_ID= _APP_VCS_GITEA_CLIENT_SECRET= _APP_VCS_GITEA_WEBHOOK_SECRET=gitea-webhook-secret +_APP_VCS_GITLAB_ENDPOINT=https://gitlab.com _APP_VCS_GITLAB_CLIENT_ID= _APP_VCS_GITLAB_CLIENT_SECRET= _APP_VCS_GITLAB_WEBHOOK_SECRET= diff --git a/app/config/console.php b/app/config/console.php index bc4c6f0b492..0d99ad71b54 100644 --- a/app/config/console.php +++ b/app/config/console.php @@ -59,7 +59,13 @@ 'githubSecret' => System::getEnv('_APP_CONSOLE_GITHUB_SECRET', ''), 'githubAppid' => System::getEnv('_APP_CONSOLE_GITHUB_APP_ID', ''), 'gitlabEnabled' => true, - 'gitlabSecret' => System::getEnv('_APP_CONSOLE_GITLAB_SECRET', ''), + // Auth\OAuth2\Gitlab reads its endpoint out of a JSON-encoded secret, so + // _APP_CONSOLE_GITLAB_ENDPOINT travels with the credential. Stays empty when + // unconfigured so account.php keeps reporting the provider as disabled. + 'gitlabSecret' => empty(System::getEnv('_APP_CONSOLE_GITLAB_SECRET', '')) ? '' : \json_encode([ + 'clientSecret' => System::getEnv('_APP_CONSOLE_GITLAB_SECRET', ''), + 'endpoint' => System::getEnv('_APP_CONSOLE_GITLAB_ENDPOINT', 'https://gitlab.com'), + ]), 'gitlabAppid' => System::getEnv('_APP_CONSOLE_GITLAB_APP_ID', ''), 'bitbucketEnabled' => true, 'bitbucketSecret' => System::getEnv('_APP_CONSOLE_BITBUCKET_SECRET', ''), diff --git a/app/config/variables.php b/app/config/variables.php index e21b086f92a..2580d0046c9 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -295,6 +295,15 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_CONSOLE_GITLAB_ENDPOINT', + 'description' => 'URL of the GitLab instance used for signing in to the Appwrite console, for self-hosted GitLab. This is separate from _APP_VCS_GITLAB_ENDPOINT, which powers repository integration rather than console sign-in.', + 'introduction' => '2.0.0', + 'default' => 'https://gitlab.com', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_CONSOLE_BITBUCKET_APP_ID', 'description' => 'Bitbucket OAuth consumer key used for signing in to the Appwrite console. You can find it in your Bitbucket workspace settings under OAuth consumers. This is separate from _APP_VCS_BITBUCKET_CLIENT_ID, which powers repository integration rather than console sign-in.', @@ -1572,6 +1581,15 @@ 'question' => '', 'filter' => '' ], + [ + 'name' => '_APP_VCS_GITLAB_ENDPOINT', + 'description' => 'URL of your self-hosted GitLab instance, reachable from the Appwrite server and the browser.', + 'introduction' => '2.0.0', + 'default' => 'https://gitlab.com', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_VCS_GITLAB_CLIENT_ID', 'description' => 'GitLab OAuth2 application client ID. You can generate one in your GitLab instance under Settings > Applications.', diff --git a/app/config/vcs.php b/app/config/vcs.php index f21d54c803f..c1c5f21deb0 100644 --- a/app/config/vcs.php +++ b/app/config/vcs.php @@ -8,6 +8,7 @@ use Appwrite\Auth\OAuth2\Gitea as OAuth2Gitea; use Appwrite\Auth\OAuth2\Github as OAuth2Github; use Appwrite\Auth\OAuth2\Gitlab as OAuth2Gitlab; +use Utopia\System\System; use Utopia\VCS\Adapter\Git\Bitbucket; use Utopia\VCS\Adapter\Git\Gitea; use Utopia\VCS\Adapter\Git\GitHub; @@ -55,8 +56,8 @@ 'clientSecret' => $clientSecret, 'endpoint' => $endpoint, ]), ''), - // Only official gitlab.com is supported -- fixed, not configurable. - 'endpoint' => 'https://gitlab.com', + // Defaults to gitlab.com; self-hosted installs point _APP_VCS_GITLAB_ENDPOINT at their own instance. + 'endpoint' => System::getEnv('_APP_VCS_GITLAB_ENDPOINT', 'https://gitlab.com'), 'variables' => [ 'clientId' => ['required' => true, 'envVariable' => '_APP_VCS_GITLAB_CLIENT_ID'], 'clientSecret' => ['required' => true, 'envVariable' => '_APP_VCS_GITLAB_CLIENT_SECRET'], diff --git a/docker-compose.yml b/docker-compose.yml index c709dc73e58..99a32c96282 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,7 @@ services: - _APP_CONSOLE_GITHUB_SECRET - _APP_CONSOLE_GITLAB_APP_ID - _APP_CONSOLE_GITLAB_SECRET + - _APP_CONSOLE_GITLAB_ENDPOINT - _APP_CONSOLE_BITBUCKET_APP_ID - _APP_CONSOLE_BITBUCKET_SECRET - _APP_CONSOLE_GOOGLE_APP_ID @@ -229,6 +230,7 @@ services: - _APP_VCS_GITEA_CLIENT_ID - _APP_VCS_GITEA_CLIENT_SECRET - _APP_VCS_GITEA_WEBHOOK_SECRET + - _APP_VCS_GITLAB_ENDPOINT - _APP_VCS_GITLAB_CLIENT_ID - _APP_VCS_GITLAB_CLIENT_SECRET - _APP_VCS_GITLAB_WEBHOOK_SECRET @@ -482,6 +484,7 @@ services: - _APP_VCS_GITEA_CLIENT_ID - _APP_VCS_GITEA_CLIENT_SECRET - _APP_VCS_GITEA_WEBHOOK_SECRET + - _APP_VCS_GITLAB_ENDPOINT - _APP_VCS_GITLAB_CLIENT_ID - _APP_VCS_GITLAB_CLIENT_SECRET - _APP_VCS_GITLAB_WEBHOOK_SECRET @@ -824,6 +827,7 @@ services: - _APP_VCS_GITEA_CLIENT_ID - _APP_VCS_GITEA_CLIENT_SECRET - _APP_VCS_GITEA_WEBHOOK_SECRET + - _APP_VCS_GITLAB_ENDPOINT - _APP_VCS_GITLAB_CLIENT_ID - _APP_VCS_GITLAB_CLIENT_SECRET - _APP_VCS_GITLAB_WEBHOOK_SECRET diff --git a/src/Appwrite/Auth/OAuth2/Gitlab.php b/src/Appwrite/Auth/OAuth2/Gitlab.php index db72385a12c..938926cf37f 100644 --- a/src/Appwrite/Auth/OAuth2/Gitlab.php +++ b/src/Appwrite/Auth/OAuth2/Gitlab.php @@ -11,8 +11,9 @@ * Shared with the "Sign in with GitLab" account-login OAuth2 provider, which * stores its secret as JSON ({"clientSecret": "...", "endpoint": "..."}) to * support self-hosted GitLab per-project. The VCS flow (see app/config/vcs.php) - * only supports official gitlab.com, but still encodes to that same JSON - * shape so getAppSecret()/getEndpoint() stay correct for both consumers. + * and the console project (see app/config/console.php) encode to that same JSON + * shape, taking their endpoint from _APP_VCS_GITLAB_ENDPOINT and + * _APP_CONSOLE_GITLAB_ENDPOINT respectively. */ class Gitlab extends OAuth2 { diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Authorize/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Authorize/Get.php index 34e8dd71b08..0eca4c99069 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Authorize/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Authorize/Get.php @@ -31,7 +31,7 @@ protected function createOAuth2(string $callback, array $state): OAuth2 System::getEnv('_APP_VCS_GITLAB_CLIENT_ID', ''), \json_encode([ 'clientSecret' => System::getEnv('_APP_VCS_GITLAB_CLIENT_SECRET', ''), - 'endpoint' => 'https://gitlab.com', + 'endpoint' => System::getEnv('_APP_VCS_GITLAB_ENDPOINT', 'https://gitlab.com'), ]), $callback, $state, diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Callback/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Callback/Get.php index 6d86b25fa1c..e48f488f5d1 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Callback/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Gitlab/Callback/Get.php @@ -30,7 +30,7 @@ protected function createOAuth2(string $callback): OAuth2 System::getEnv('_APP_VCS_GITLAB_CLIENT_ID', ''), \json_encode([ 'clientSecret' => System::getEnv('_APP_VCS_GITLAB_CLIENT_SECRET', ''), - 'endpoint' => 'https://gitlab.com', + 'endpoint' => System::getEnv('_APP_VCS_GITLAB_ENDPOINT', 'https://gitlab.com'), ]), $callback ); From 1bae8729749550de890c03970311bac1075dae8a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Tue, 1 Sep 2026 12:07:52 +0530 Subject: [PATCH 10/18] fix(oauth2): stop GitLab rejecting a plain-string secret getAppSecret() decoded appSecret with JSON_THROW_ON_ERROR and turned any failure into "Invalid secret", so a secret that is not a JSON object took down every call that needed it. The legacy PATCH /v1/projects/:projectId/oauth2 writes the secret verbatim for any provider in the oAuthProviders config, gitlab included, so projects configured through it fail sign-in outright. A numeric secret decoded fine and then tripped the array return type instead. Fall back to treating the raw string as the client secret, matching Google, which grew the same fallback for the same reason. Also rtrim the endpoint, as Gitea's setEndpoint() does, so a configured endpoint with a trailing slash no longer builds //oauth/authorize. --- src/Appwrite/Auth/OAuth2/Gitlab.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Appwrite/Auth/OAuth2/Gitlab.php b/src/Appwrite/Auth/OAuth2/Gitlab.php index 938926cf37f..fd2464b4ff0 100644 --- a/src/Appwrite/Auth/OAuth2/Gitlab.php +++ b/src/Appwrite/Auth/OAuth2/Gitlab.php @@ -231,7 +231,8 @@ protected function getUser(string $accessToken): array } /** - * Decode the JSON stored in appSecret + * Decode the JSON stored in appSecret. + * Falls back to treating the raw string as the client secret for backwards compatibility. * * @return array */ @@ -240,8 +241,13 @@ protected function getAppSecret(): array try { $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); } catch (\Throwable $th) { - throw new \Exception('Invalid secret'); + return ['clientSecret' => $this->appSecret]; } + + if (!\is_array($secret)) { + return ['clientSecret' => $this->appSecret]; + } + return $secret; } @@ -256,6 +262,6 @@ protected function getEndpoint(): string $defaultEndpoint = 'https://gitlab.com'; $secret = $this->getAppSecret(); $endpoint = $secret['endpoint'] ?? $defaultEndpoint; - return empty($endpoint) ? $defaultEndpoint : $endpoint; + return empty($endpoint) ? $defaultEndpoint : \rtrim($endpoint, '/'); } } From 47d917dac6f686be408cff93524db15512fdf881 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 1 Sep 2026 11:22:49 +0300 Subject: [PATCH 11/18] Column boundaries for numeric attributes --- .../Collections/Attributes/Integer/Create.php | 5 +- .../Http/Databases/Collections/Create.php | 14 +- src/Appwrite/Utopia/Database/Attribute.php | 12 ++ .../Utopia/Database/Validator/Attributes.php | 17 ++ .../e2e/Services/Databases/DatabasesBase.php | 164 ++++++++++++++++++ .../Database/Validator/AttributesTest.php | 80 +++++++++ 6 files changed, 288 insertions(+), 4 deletions(-) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php index 022efb2062c..5647546661b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Attributes/Integer/Create.php @@ -94,7 +94,10 @@ public function action(string $databaseId, string $collectionId, string $key, ?b throw new Exception($this->getInvalidValueException(), $validator->getDescription()); } - $size = $max > 2147483647 ? 8 : 4; + // The 4 byte column only holds a range that fits INT32. min counts: a + // column bounded below -2147483648 has to be able to store that value, + // and with min left out the bound is PHP_INT_MIN. + $size = $min >= -2147483648 && $max <= 2147483647 ? 4 : 8; $attribute = $this->createAttribute($databaseId, $collectionId, new Document([ 'key' => $key, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php index 0db84db7f30..0d0beb0003e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php @@ -297,16 +297,24 @@ protected function buildAttributeDocument( $formatOptions = ['elements' => $attribute['elements']]; } - if (isset($attribute['min']) || isset($attribute['max'])) { + // The dedicated endpoints store a range on every numeric attribute, falling + // back to the full width of the type, so omitting min/max here has to produce + // the same document rather than one with no range at all. + if (\in_array($type, [Database::VAR_INTEGER, Database::VAR_BIGINT, Database::VAR_FLOAT])) { + $isFloat = $type === Database::VAR_FLOAT; + $format = match($type) { Database::VAR_INTEGER => APP_DATABASE_ATTRIBUTE_INT_RANGE, Database::VAR_BIGINT => APP_DATABASE_ATTRIBUTE_BIGINT_RANGE, default => APP_DATABASE_ATTRIBUTE_FLOAT_RANGE, }; + $min = $attribute['min'] ?? ($isFloat ? -\PHP_FLOAT_MAX : \PHP_INT_MIN); + $max = $attribute['max'] ?? ($isFloat ? \PHP_FLOAT_MAX : \PHP_INT_MAX); + $formatOptions = [ - 'min' => $attribute['min'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MIN : -\PHP_FLOAT_MAX), - 'max' => $attribute['max'] ?? ($type === Database::VAR_INTEGER || $type === Database::VAR_BIGINT ? \PHP_INT_MAX : \PHP_FLOAT_MAX), + 'min' => $isFloat ? \floatval($min) : $min, + 'max' => $isFloat ? \floatval($max) : $max, ]; } diff --git a/src/Appwrite/Utopia/Database/Attribute.php b/src/Appwrite/Utopia/Database/Attribute.php index b4dba3433c1..010e27a7e5c 100644 --- a/src/Appwrite/Utopia/Database/Attribute.php +++ b/src/Appwrite/Utopia/Database/Attribute.php @@ -22,6 +22,8 @@ class Attribute Database::VAR_TEXT => 65535, Database::VAR_MEDIUMTEXT => 16777215, Database::VAR_LONGTEXT => 2147483647, + // Bytes, the width createBigIntColumn hardcodes + Database::VAR_BIGINT => 8, ]; /** @@ -91,6 +93,16 @@ public static function resolve(array $attribute): array $size = self::FORMAT_SIZES[$format] ?? $size; } + if ($type === Database::VAR_INTEGER && $size < 1) { + // Same width createIntegerColumn picks: the 4 byte column only holds a + // range that fits INT32, and a bound left out means the int64 edge, + // which does not. + $min = $attribute['min'] ?? \PHP_INT_MIN; + $max = $attribute['max'] ?? \PHP_INT_MAX; + $fitsInt32 = \is_int($min) && \is_int($max) && $min >= -2147483648 && $max <= 2147483647; + $size = $fitsInt32 ? 4 : 8; + } + return [ 'type' => $type, 'format' => $format, diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index 4eefb9ba2d0..3b634d6989e 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -8,6 +8,8 @@ use Utopia\Database\Validator\Key; use Utopia\Emails\Validator\Email; use Utopia\Validator; +use Utopia\Validator\FloatValidator; +use Utopia\Validator\Integer; use Utopia\Validator\IP; use Utopia\Validator\Range; use Utopia\Validator\Text; @@ -204,6 +206,21 @@ public function isValid($value): bool return false; } + // The dedicated endpoints bound these with Integer(false, 64) / FloatValidator. + // Without the same check a JSON number past PHP_INT_MAX decodes to a float and is + // stored verbatim, so an int64 bound round-tripped through a client that cannot + // hold it lands in formatOptions as 9.223372036854776e+18. + $boundValidator = $type === Database::VAR_FLOAT + ? new FloatValidator() + : new Integer(false, 64); + + foreach (['min', 'max'] as $bound) { + if (isset($attribute[$bound]) && !$boundValidator->isValid($attribute[$bound])) { + $this->message = "Attribute '" . $attribute['key'] . "': " . $bound . ' is invalid. ' . $boundValidator->getDescription(); + return false; + } + } + // If both are set, validate ordering if (isset($attribute['min']) && isset($attribute['max']) && $attribute['min'] > $attribute['max']) { $this->message = "Attribute '" . $attribute['key'] . "': minimum value must be less than or equal to maximum value"; diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index bd0808fd697..4ba282050b0 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -1223,6 +1223,170 @@ public function testCreateAttributes(): void } } + /** + * An inline numeric definition on create collection has to produce the same + * document the dedicated endpoints write: the full width of the type when no + * bounds are given, on a column wide enough to hold it. + */ + public function testCreateCollectionInlineNumericRange(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + } + + $data = $this->setupDatabase(); + $databaseId = $data['databaseId']; + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $schemaResource = $this->getSchemaResource(); + + $container = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), $headers, [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Counters', + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + $schemaResource => [ + ['key' => 'counter', 'type' => Database::VAR_INTEGER], + ['key' => 'total', 'type' => Database::VAR_BIGINT], + ['key' => 'ratio', 'type' => Database::VAR_FLOAT], + ['key' => 'bounded', 'type' => Database::VAR_INTEGER, 'min' => 0, 'max' => 100], + ], + ]); + + $this->assertEquals(201, $container['headers']['status-code']); + $containerId = $container['body']['$id']; + + $schema = $this->client->call(Client::METHOD_GET, $this->getSchemaUrl($databaseId, $containerId), $headers); + + $this->assertEquals(200, $schema['headers']['status-code']); + + $byKey = []; + foreach ($schema['body'][$schemaResource] as $attribute) { + $byKey[$attribute['key']] = $attribute; + } + + $this->assertSame(\PHP_INT_MIN, $byKey['counter']['min']); + $this->assertSame(\PHP_INT_MAX, $byKey['counter']['max']); + $this->assertSame(\PHP_INT_MIN, $byKey['total']['min']); + $this->assertSame(\PHP_INT_MAX, $byKey['total']['max']); + $this->assertSame(-\PHP_FLOAT_MAX, $byKey['ratio']['min']); + $this->assertSame(\PHP_FLOAT_MAX, $byKey['ratio']['max']); + + // A bound that was asked for is kept as asked for + $this->assertSame(0, $byKey['bounded']['min']); + $this->assertSame(100, $byKey['bounded']['max']); + + // The bounds are only honest if the column is that wide. 5e9 overflows the + // 4 byte column an implied INT32 range would have produced. + $record = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $containerId), $headers, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'counter' => 5000000000, + 'total' => \PHP_INT_MAX, + ], + ]); + + $this->assertEquals(201, $record['headers']['status-code']); + $this->assertSame(5000000000, $record['body']['counter']); + $this->assertSame(\PHP_INT_MAX, $record['body']['total']); + + // A value outside a declared bound is still refused + $rejected = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $containerId), $headers, [ + $this->getRecordIdParam() => ID::unique(), + 'data' => ['bounded' => 101], + ]); + + $this->assertEquals(400, $rejected['headers']['status-code']); + + // Same type, same collection, through the dedicated endpoint: the two paths + // have to agree on what "no bounds" means + $dedicated = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $containerId, 'integer'), $headers, [ + 'key' => 'dedicated', + 'required' => false, + ]); + + $this->assertEquals(202, $dedicated['headers']['status-code']); + $this->assertSame($byKey['counter']['min'], $dedicated['body']['min']); + $this->assertSame($byKey['counter']['max'], $dedicated['body']['max']); + } + + /** + * A JSON number past PHP_INT_MAX decodes to a float. The dedicated endpoints + * refuse it; an inline definition used to store it, leaving formatOptions + * holding 9.223372036854776e+18 on a column typed as an integer. + */ + public function testCreateCollectionInlineBoundBeyondInt64(): void + { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + } + + $data = $this->setupDatabase(); + $databaseId = $data['databaseId']; + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]; + $schemaResource = $this->getSchemaResource(); + $containerId = ID::unique(); + + $container = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), $headers, [ + $this->getContainerIdParam() => $containerId, + 'name' => 'Overflowing Bounds', + $schemaResource => [ + // What a client that cannot hold an int64 sends back after reading + // the default bounds off an existing column + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => -9223372036854776000, 'max' => 9223372036854776000], + ], + ]); + + $this->assertEquals(400, $container['headers']['status-code']); + $this->assertStringContainsString("Attribute 'counter'", (string) $container['body']['message']); + + // The rejected definition must not leave a collection behind + $missing = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $containerId), $headers); + $this->assertEquals(404, $missing['headers']['status-code']); + + // A bound sent as a string is not an integer either + $stringBound = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), $headers, [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'String Bounds', + $schemaResource => [ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => '9223372036854776000'], + ], + ]); + + $this->assertEquals(400, $stringBound['headers']['status-code']); + $this->assertStringContainsString("Attribute 'counter'", (string) $stringBound['body']['message']); + + // The dedicated endpoint is the reference: it refuses the same bound + $valid = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), $headers, [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Dedicated Bounds', + ]); + + $this->assertEquals(201, $valid['headers']['status-code']); + + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $valid['body']['$id'], 'integer'), $headers, [ + 'key' => 'counter', + 'required' => false, + 'max' => 9223372036854776000, + ]); + + $this->assertEquals(400, $attribute['headers']['status-code']); + + $schema = $this->client->call(Client::METHOD_GET, $this->getSchemaUrl($databaseId, $valid['body']['$id']), $headers); + + $this->assertEquals(200, $schema['headers']['status-code']); + $this->assertEquals(0, $schema['body']['total']); + } + public function testListAttributes(): void { if (!$this->getSupportForAttributes()) { diff --git a/tests/unit/Utopia/Database/Validator/AttributesTest.php b/tests/unit/Utopia/Database/Validator/AttributesTest.php index 6c02e5bbe79..34212a8b0c9 100644 --- a/tests/unit/Utopia/Database/Validator/AttributesTest.php +++ b/tests/unit/Utopia/Database/Validator/AttributesTest.php @@ -120,6 +120,86 @@ public function testResolveMatchesDedicatedEndpoints(): void ['type' => Database::VAR_STRING, 'format' => APP_DATABASE_ATTRIBUTE_ENUM, 'size' => Database::LENGTH_KEY], Attribute::resolve(['key' => 'enum', 'type' => APP_DATABASE_ATTRIBUTE_ENUM]) ); + + // createIntegerColumn sizes the column off max, defaulting to the int64 + // range, and createBigIntColumn is always 8 bytes + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER]) + ); + + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => 3000000000]) + ); + + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 4], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => 0, 'max' => 100]) + ); + + // Both edges of the declared range have to be storable. A max on its own + // leaves min at PHP_INT_MIN, and a min below INT32 needs the wide column + // however small max is. + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => 100]) + ); + + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => -5000000000, 'max' => 100]) + ); + + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 4], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => -2147483648, 'max' => 2147483647]) + ); + + $this->assertEquals( + ['type' => Database::VAR_BIGINT, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'total', 'type' => Database::VAR_BIGINT]) + ); + } + + public function testNumericBoundsMustFitTheType(): void + { + $this->assertTrue($this->object->isValid([ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => 0, 'max' => 100], + ['key' => 'total', 'type' => Database::VAR_BIGINT, 'min' => \PHP_INT_MIN, 'max' => \PHP_INT_MAX], + ['key' => 'ratio', 'type' => Database::VAR_FLOAT, 'min' => -1.5, 'max' => 1.5], + ]), $this->object->getDescription()); + + // 9223372036854776000 is what a client that rounds an int64 to a double + // sends back after reading the default bounds off an existing column. PHP + // decodes it as a float, and storing it leaves an integer column bounded + // by 9.223372036854776e+18. + $this->assertFalse($this->object->isValid([ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'min' => -9223372036854776000, 'max' => 9223372036854776000], + ])); + $this->assertStringContainsString("Attribute 'counter': min is invalid", $this->object->getDescription()); + + $this->assertFalse($this->object->isValid([ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => 9223372036854776000], + ])); + $this->assertStringContainsString("Attribute 'counter': max is invalid", $this->object->getDescription()); + + $this->assertFalse($this->object->isValid([ + ['key' => 'total', 'type' => Database::VAR_BIGINT, 'max' => 9223372036854776000], + ])); + + $this->assertFalse($this->object->isValid([ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => 1.5], + ])); + + $this->assertFalse($this->object->isValid([ + ['key' => 'counter', 'type' => Database::VAR_INTEGER, 'max' => '100'], + ])); + + // A float column carries the same bound as a double, which is what it is + $this->assertTrue($this->object->isValid([ + ['key' => 'ratio', 'type' => Database::VAR_FLOAT, 'max' => 9223372036854776000], + ]), $this->object->getDescription()); } public function testResolveKeepsExplicitSize(): void From 40c61dc5cd2a4778dec77e140d0a1d7eaeacc39f Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 1 Sep 2026 15:21:30 +0300 Subject: [PATCH 12/18] Fix comments --- src/Appwrite/Utopia/Database/Attribute.php | 14 ++++++---- .../e2e/Services/Databases/DatabasesBase.php | 7 +++++ .../Database/Validator/AttributesTest.php | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/Appwrite/Utopia/Database/Attribute.php b/src/Appwrite/Utopia/Database/Attribute.php index 010e27a7e5c..61b3dc18954 100644 --- a/src/Appwrite/Utopia/Database/Attribute.php +++ b/src/Appwrite/Utopia/Database/Attribute.php @@ -22,8 +22,10 @@ class Attribute Database::VAR_TEXT => 65535, Database::VAR_MEDIUMTEXT => 16777215, Database::VAR_LONGTEXT => 2147483647, - // Bytes, the width createBigIntColumn hardcodes + // Bytes, the widths createBigIntColumn and createFloatColumn hardcode. + // Every adapter maps these two types without consulting the size. Database::VAR_BIGINT => 8, + Database::VAR_FLOAT => 0, ]; /** @@ -93,10 +95,12 @@ public static function resolve(array $attribute): array $size = self::FORMAT_SIZES[$format] ?? $size; } - if ($type === Database::VAR_INTEGER && $size < 1) { - // Same width createIntegerColumn picks: the 4 byte column only holds a - // range that fits INT32, and a bound left out means the int64 edge, - // which does not. + if ($type === Database::VAR_INTEGER) { + // Same width createIntegerColumn picks. That endpoint takes no size at + // all, so a size sent inline is ignored rather than left to promise a + // range the column cannot hold: the 4 byte column only holds a range + // that fits INT32, and a bound left out means the int64 edge, which + // does not. $min = $attribute['min'] ?? \PHP_INT_MIN; $max = $attribute['max'] ?? \PHP_INT_MAX; $fitsInt32 = \is_int($min) && \is_int($max) && $min >= -2147483648 && $max <= 2147483647; diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 4ba282050b0..39b2bb29ffa 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -1255,6 +1255,9 @@ public function testCreateCollectionInlineNumericRange(): void ['key' => 'total', 'type' => Database::VAR_BIGINT], ['key' => 'ratio', 'type' => Database::VAR_FLOAT], ['key' => 'bounded', 'type' => Database::VAR_INTEGER, 'min' => 0, 'max' => 100], + // The numeric endpoints take no size, so this one cannot narrow the + // column below the int64 range the definition still declares + ['key' => 'sized', 'type' => Database::VAR_INTEGER, 'size' => 4], ], ]); @@ -1270,6 +1273,8 @@ public function testCreateCollectionInlineNumericRange(): void $byKey[$attribute['key']] = $attribute; } + $this->assertSame(\PHP_INT_MIN, $byKey['sized']['min']); + $this->assertSame(\PHP_INT_MAX, $byKey['sized']['max']); $this->assertSame(\PHP_INT_MIN, $byKey['counter']['min']); $this->assertSame(\PHP_INT_MAX, $byKey['counter']['max']); $this->assertSame(\PHP_INT_MIN, $byKey['total']['min']); @@ -1288,12 +1293,14 @@ public function testCreateCollectionInlineNumericRange(): void 'data' => [ 'counter' => 5000000000, 'total' => \PHP_INT_MAX, + 'sized' => 5000000000, ], ]); $this->assertEquals(201, $record['headers']['status-code']); $this->assertSame(5000000000, $record['body']['counter']); $this->assertSame(\PHP_INT_MAX, $record['body']['total']); + $this->assertSame(5000000000, $record['body']['sized']); // A value outside a declared bound is still refused $rejected = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $containerId), $headers, [ diff --git a/tests/unit/Utopia/Database/Validator/AttributesTest.php b/tests/unit/Utopia/Database/Validator/AttributesTest.php index 34212a8b0c9..7bb46d9fa6b 100644 --- a/tests/unit/Utopia/Database/Validator/AttributesTest.php +++ b/tests/unit/Utopia/Database/Validator/AttributesTest.php @@ -160,6 +160,33 @@ public function testResolveMatchesDedicatedEndpoints(): void ['type' => Database::VAR_BIGINT, 'format' => '', 'size' => 8], Attribute::resolve(['key' => 'total', 'type' => Database::VAR_BIGINT]) ); + + $this->assertEquals( + ['type' => Database::VAR_FLOAT, 'format' => '', 'size' => 0], + Attribute::resolve(['key' => 'ratio', 'type' => Database::VAR_FLOAT]) + ); + + // None of the numeric endpoints takes a size. A size sent inline must not + // narrow the column below the range the same definition declares. + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'size' => 4]) + ); + + $this->assertEquals( + ['type' => Database::VAR_INTEGER, 'format' => '', 'size' => 4], + Attribute::resolve(['key' => 'counter', 'type' => Database::VAR_INTEGER, 'size' => 8, 'min' => 0, 'max' => 100]) + ); + + $this->assertEquals( + ['type' => Database::VAR_BIGINT, 'format' => '', 'size' => 8], + Attribute::resolve(['key' => 'total', 'type' => Database::VAR_BIGINT, 'size' => 4]) + ); + + $this->assertEquals( + ['type' => Database::VAR_FLOAT, 'format' => '', 'size' => 0], + Attribute::resolve(['key' => 'ratio', 'type' => Database::VAR_FLOAT, 'size' => 4]) + ); } public function testNumericBoundsMustFitTheType(): void From a38e90dcf4266a5a5c6e342e52c2dd00ba2d79e8 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 2 Sep 2026 13:50:44 +0530 Subject: [PATCH 13/18] chore: ship the GitLab endpoint variables empty System::getEnv is getenv($name) ?: $default, so a set-but-empty variable falls through to the https://gitlab.com default the config already carries. Shipping the value blank keeps the sample env and a generated install free of a URL that only self-hosted GitLab needs to change, matching the Gitea endpoint entry. The effective default moves into the descriptions. --- .env | 4 ++-- app/config/variables.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.env b/.env index 7fe753006fa..6fcb35a3159 100644 --- a/.env +++ b/.env @@ -41,7 +41,7 @@ _APP_CONSOLE_GITHUB_APP_ID= _APP_CONSOLE_GITHUB_SECRET= _APP_CONSOLE_GITLAB_APP_ID= _APP_CONSOLE_GITLAB_SECRET= -_APP_CONSOLE_GITLAB_ENDPOINT=https://gitlab.com +_APP_CONSOLE_GITLAB_ENDPOINT= _APP_CONSOLE_BITBUCKET_APP_ID= _APP_CONSOLE_BITBUCKET_SECRET= _APP_CONSOLE_GOOGLE_APP_ID= @@ -182,7 +182,7 @@ _APP_VCS_GITEA_BROWSER_ENDPOINT=http://localhost:9515 _APP_VCS_GITEA_CLIENT_ID= _APP_VCS_GITEA_CLIENT_SECRET= _APP_VCS_GITEA_WEBHOOK_SECRET=gitea-webhook-secret -_APP_VCS_GITLAB_ENDPOINT=https://gitlab.com +_APP_VCS_GITLAB_ENDPOINT= _APP_VCS_GITLAB_CLIENT_ID= _APP_VCS_GITLAB_CLIENT_SECRET= _APP_VCS_GITLAB_WEBHOOK_SECRET= diff --git a/app/config/variables.php b/app/config/variables.php index 2580d0046c9..4b69212e75b 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -297,9 +297,9 @@ ], [ 'name' => '_APP_CONSOLE_GITLAB_ENDPOINT', - 'description' => 'URL of the GitLab instance used for signing in to the Appwrite console, for self-hosted GitLab. This is separate from _APP_VCS_GITLAB_ENDPOINT, which powers repository integration rather than console sign-in.', + 'description' => 'URL of the GitLab instance used for signing in to the Appwrite console, for self-hosted GitLab. Defaults to https://gitlab.com when unset. This is separate from _APP_VCS_GITLAB_ENDPOINT, which powers repository integration rather than console sign-in.', 'introduction' => '2.0.0', - 'default' => 'https://gitlab.com', + 'default' => '', 'required' => false, 'question' => '', 'filter' => '' @@ -1583,9 +1583,9 @@ ], [ 'name' => '_APP_VCS_GITLAB_ENDPOINT', - 'description' => 'URL of your self-hosted GitLab instance, reachable from the Appwrite server and the browser.', + 'description' => 'URL of your self-hosted GitLab instance, reachable from the Appwrite server and the browser. Defaults to https://gitlab.com when unset.', 'introduction' => '2.0.0', - 'default' => 'https://gitlab.com', + 'default' => '', 'required' => false, 'question' => '', 'filter' => '' From 97da563b0913232ff6040f081331df21d845c2ce Mon Sep 17 00:00:00 2001 From: Atharva Deosthale Date: Wed, 2 Sep 2026 17:56:09 +0530 Subject: [PATCH 14/18] fix(databases): expose transactionId on DocumentsDB and VectorsDB create SDK methods The createDocument and createDocuments routes for DocumentsDB and VectorsDB already accept a transactionId body parameter, but their SDK Method definitions omit it from the parameter allow-list, so the generated SDKs cannot stage document creation inside a transaction. TablesDB's createRow and createRows already list it. Mirror that here. --- .../Databases/Http/DocumentsDB/Collections/Documents/Create.php | 2 ++ .../Databases/Http/VectorsDB/Collections/Documents/Create.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php index 90f65fd5169..ea7c96c1b42 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -76,6 +76,7 @@ public function __construct() new Parameter('documentId', optional: false), new Parameter('data', optional: false), new Parameter('permissions', optional: true), + new Parameter('transactionId', optional: true), ] ), new Method( @@ -96,6 +97,7 @@ public function __construct() new Parameter('databaseId', optional: false), new Parameter('collectionId', optional: false), new Parameter('documents', optional: false), + new Parameter('transactionId', optional: true), ] ) ]) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php index 86488634c35..680308544f0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php @@ -70,6 +70,7 @@ public function __construct() new Parameter('documentId', optional: false), new Parameter('data', optional: false), new Parameter('permissions', optional: true), + new Parameter('transactionId', optional: true), ] ), new Method( @@ -90,6 +91,7 @@ public function __construct() new Parameter('databaseId', optional: false), new Parameter('collectionId', optional: false), new Parameter('documents', optional: false), + new Parameter('transactionId', optional: true), ] ) ]) From bdd63a8bd6ed513c960a2e53c0cf8c24763e2b63 Mon Sep 17 00:00:00 2001 From: "Luke B. Silver" <22452787+loks0n@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:26:09 +0100 Subject: [PATCH 15/18] refactor(queues): remove intermediate publisher resources (#13449) --- app/init/resources.php | 20 +++----------------- app/init/resources/request.php | 8 ++++---- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/app/init/resources.php b/app/init/resources.php index 757dc404c24..35252bec172 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -109,20 +109,6 @@ $container->set('publisher', fn (Group $pools) => new BrokerPool(publisher: $pools->get('publisher')), ['pools']); -$container->set('publisherDatabases', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherFunctions', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherMigrations', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherMails', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherDeletes', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherMessaging', fn (Publisher $publisher) => $publisher, ['publisher']); - -$container->set('publisherWebhooks', fn (Publisher $publisher) => $publisher, ['publisher']); - $container->set('publisherForAudits', fn (Publisher $publisher) => new AuditPublisher( $publisher, new Queue(System::getEnv('_APP_AUDITS_QUEUE_NAME', Event::AUDITS_QUEUE_NAME)) @@ -204,10 +190,10 @@ new Queue(System::getEnv('_APP_JOBS_QUEUE_NAME', Event::JOBS_QUEUE_NAME)) ), ['publisher']); -$container->set('publisherForDatabase', fn (Publisher $publisherDatabases) => new DatabasePublisher( - $publisherDatabases, +$container->set('publisherForDatabase', fn (Publisher $publisher) => new DatabasePublisher( + $publisher, new Queue(System::getEnv('_APP_DATABASE_QUEUE_NAME', Event::DATABASE_QUEUE_NAME)) -), ['publisherDatabases']); +), ['publisher']); $container->set('publisherForDeletes', fn (Publisher $publisher) => new DeletePublisher( $publisher, diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 8039571243b..8aa7f7cf707 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -672,7 +672,7 @@ return; }, ['user', 'store', 'proofForToken']); - $context->set('dbForProject', function (DatabaseFactory $databaseFactory, Database $dbForPlatform, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Request $request) { + $context->set('dbForProject', function (DatabaseFactory $databaseFactory, Database $dbForPlatform, Document $project, Response $response, Publisher $publisher, Event $queueForEvents, FunctionPublisher $publisherForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Request $request) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; } @@ -796,7 +796,7 @@ // Clone the queues, to prevent events triggered by the database listener // from overwriting the events that are supposed to be triggered in the shutdown hook. $queueForEventsClone = new Event($publisher); - $queueForWebhooks = new Webhook($publisherWebhooks); + $queueForWebhooksClone = clone $queueForWebhooks; $queueForRealtime = new Realtime(); $database @@ -811,7 +811,7 @@ $response, $queueForEventsClone->from($queueForEvents), $publisherForFunctions, - $queueForWebhooks->from($queueForEvents), + $queueForWebhooksClone->from($queueForEvents), $queueForRealtime->from($queueForEvents) )) ->on(Database::EVENT_DOCUMENT_CREATE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)) @@ -819,7 +819,7 @@ ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)); return $database; - }, ['databaseFactory', 'dbForPlatform', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'publisherForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'request']); + }, ['databaseFactory', 'dbForPlatform', 'project', 'response', 'publisher', 'queueForEvents', 'publisherForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'request']); $context->set('schema', function ($utopia, $dbForProject, $authorization) { From ab4ce263dc2c8074309567adc9d3431e6d69e3b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:35:41 +0000 Subject: [PATCH 16/18] test(databases): cover transactionId on DocumentsDB and VectorsDB create Lock createDocument/createDocuments SDK method metadata so generated clients keep transactionId, and reject unknown transaction IDs on the create and bulk-create routes. Co-authored-by: Jake Barnby --- .../Transactions/TransactionsBase.php | 41 +++++++ .../Transactions/TransactionsBase.php | 45 +++++++ .../Http/CreateTransactionIdTest.php | 70 +++++++++++ tests/unit/SDK/Specification/FormatTest.php | 111 ++++++++++++++++++ 4 files changed, 267 insertions(+) create mode 100644 tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index a49b9cf25bf..16f235a4733 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -1785,6 +1785,26 @@ public function testCreateDocument(): void $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('Created via normal route', $response['body']['name']); + + /** + * Test for FAILURE + */ + $unknown = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + $this->getRecordIdParam() => 'doc_unknown_txn', + 'data' => [ + 'name' => 'Unknown transaction', + 'counter' => 1, + 'category' => 'test' + ], + 'transactionId' => ID::unique() + ]); + + $this->assertEquals(404, $unknown['headers']['status-code']); + $this->assertEquals(Exception::TRANSACTION_NOT_FOUND, $unknown['body']['type']); } /** @@ -2267,6 +2287,27 @@ public function testBulkCreate(): void $this->assertEquals("Bulk created {$i}", $response['body']['name']); $this->assertEquals('bulk_created', $response['body']['category']); } + + /** + * Test for FAILURE + */ + $unknown = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + $this->getRecordResource() => [ + [ + '$id' => 'bulk_unknown_txn', + 'name' => 'Unknown transaction', + 'category' => 'bulk_unknown' + ] + ], + 'transactionId' => ID::unique() + ]); + + $this->assertEquals(404, $unknown['headers']['status-code']); + $this->assertEquals(Exception::TRANSACTION_NOT_FOUND, $unknown['body']['type']); } /** diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php index b8cc9f6f0d2..9821685ac25 100644 --- a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php @@ -2,6 +2,7 @@ namespace Tests\E2E\Services\Databases\VectorsDB\Transactions; +use Appwrite\Extend\Exception; use Tests\E2E\Client; use Utopia\Database\Database; use Utopia\Database\Helpers\ID; @@ -1489,6 +1490,27 @@ public function testCreateDocument(): void $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('Created via normal route', $response['body']['metadata']['name']); + + /** + * Test for FAILURE + */ + $unknown = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_unknown_txn', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Unknown transaction', + ] + ], + 'transactionId' => ID::unique() + ]); + + $this->assertEquals(404, $unknown['headers']['status-code']); + $this->assertEquals(Exception::TRANSACTION_NOT_FOUND, $unknown['body']['type']); } /** @@ -1933,6 +1955,29 @@ public function testBulkCreate(): void $this->assertEquals("Bulk created {$i}", $response['body']['metadata']['name']); $this->assertEquals('bulk_created', $response['body']['metadata']['category']); } + + /** + * Test for FAILURE + */ + $unknown = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => 'bulk_unknown_txn', + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Unknown transaction', + ] + ] + ], + 'transactionId' => ID::unique() + ]); + + $this->assertEquals(404, $unknown['headers']['status-code']); + $this->assertEquals(Exception::TRANSACTION_NOT_FOUND, $unknown['body']['type']); } /** diff --git a/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php b/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php new file mode 100644 index 00000000000..c461e43f155 --- /dev/null +++ b/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php @@ -0,0 +1,70 @@ + $expected + */ + #[DataProvider('createRoutes')] + public function testCreateSdkMethodsExposeTransactionId(Route $route, array $expected): void + { + $methods = $route->getLabel('sdk'); + $this->assertIsArray($methods); + + $names = []; + foreach ($methods as $method) { + $this->assertInstanceOf(Method::class, $method); + $names[$method->getMethodName()] = \array_map( + static fn (Parameter $parameter): string => $parameter->getName(), + $method->getParameters(), + ); + } + + foreach ($expected as $methodName) { + $this->assertArrayHasKey($methodName, $names); + $this->assertContains( + 'transactionId', + $names[$methodName], + $route->getPath() . ' ' . $methodName . ' must expose transactionId', + ); + } + } + + /** + * @return array}> + */ + public static function createRoutes(): array + { + Method::$processed = []; + Method::$errors = []; + + return [ + 'documentsdb' => [ + new DocumentsDBCreate(), + ['createDocument', 'createDocuments'], + ], + 'vectorsdb' => [ + new VectorsDBCreate(), + ['createDocument', 'createDocuments'], + ], + ]; + } +} diff --git a/tests/unit/SDK/Specification/FormatTest.php b/tests/unit/SDK/Specification/FormatTest.php index 96d05875cf2..61754fbf57d 100644 --- a/tests/unit/SDK/Specification/FormatTest.php +++ b/tests/unit/SDK/Specification/FormatTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\SDK\Specification; +use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Parameter; @@ -1210,4 +1211,114 @@ public function testValidatorsWithoutAnExampleFallBackToOne(): void $this->assertSame('example.com', $properties['domain']['example']); $this->assertSame('FFFFFF', $properties['background']['example']); } + + public function testExplicitMethodParametersAreTheSdkMethodList(): void + { + Method::$processed = []; + Method::$errors = []; + + $route = $this->createDocumentsRoute( + createDocument: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documentId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + new Parameter('transactionId', optional: true), + ], + createDocuments: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + new Parameter('transactionId', optional: true), + ], + ); + + $methods = $this->sdkMethods((new OpenAPI3(new Container(), [], [$route], [], [], 0, 'console'))->parse()); + + $this->assertContains('transactionId', $methods['createDocument']); + $this->assertContains('transactionId', $methods['createDocuments']); + } + + public function testOmittedMethodParametersAreDroppedFromTheSdkMethodList(): void + { + Method::$processed = []; + Method::$errors = []; + + $route = $this->createDocumentsRoute( + createDocument: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documentId', optional: false), + new Parameter('data', optional: false), + new Parameter('permissions', optional: true), + ], + createDocuments: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ], + ); + + $methods = $this->sdkMethods((new OpenAPI3(new Container(), [], [$route], [], [], 0, 'console'))->parse()); + + $this->assertNotContains('transactionId', $methods['createDocument']); + $this->assertNotContains('transactionId', $methods['createDocuments']); + } + + /** + * @param list $createDocument + * @param list $createDocuments + */ + private function createDocumentsRoute(array $createDocument, array $createDocuments): Route + { + return (new Route('POST', '/v1/documentsdb/:databaseId/collections/:collectionId/documents')) + ->desc('Create document') + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: 'documents', + name: 'createDocument', + description: 'Create document.', + auth: [AuthType::ADMIN], + responses: [], + parameters: $createDocument, + ), + new Method( + namespace: 'documentsDB', + group: 'documents', + name: 'createDocuments', + description: 'Create documents.', + auth: [AuthType::ADMIN], + responses: [], + parameters: $createDocuments, + ), + ]) + ->param('databaseId', '', new Text(256), 'Database ID.') + ->param('collectionId', '', new Text(256), 'Collection ID.') + ->param('documentId', '', new Text(256), 'Document ID.', true) + ->param('data', [], new JSON(), 'Document data.', true) + ->param('permissions', null, new Nullable(new Text(256)), 'Permissions.', true) + ->param('documents', [], new JSON(), 'Documents.', true) + ->param('transactionId', null, new Nullable(new Text(256)), 'Transaction ID.', true); + } + + /** + * @return array> + */ + private function sdkMethods(array $openApi): array + { + $path = '/documentsdb/{databaseId}/collections/{collectionId}/documents'; + $this->assertArrayHasKey($path, $openApi['paths']); + + $methods = []; + foreach ($openApi['paths'][$path]['post']['x-appwrite']['methods'] as $method) { + $methods[$method['name']] = $method['parameters']; + } + + $this->assertArrayHasKey('createDocument', $methods); + $this->assertArrayHasKey('createDocuments', $methods); + + return $methods; + } } From 442390482454a084b37f7bc9fe389bf11e696d7b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:36:04 +0000 Subject: [PATCH 17/18] test(databases): bootstrap init.php in create transactionId unit test Co-authored-by: Jake Barnby --- .../Platform/Modules/Databases/Http/CreateTransactionIdTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php b/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php index c461e43f155..8d9890e9594 100644 --- a/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php +++ b/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php @@ -12,6 +12,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Http\Route; +require_once __DIR__ . '/../../../../../../app/init.php'; require_once __DIR__ . '/../../../../../../src/Appwrite/Platform/Modules/Databases/Constants.php'; final class CreateTransactionIdTest extends TestCase From 4d1487fbb0fa2c45174d575cf4f582a9621f5228 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 12:37:36 +0000 Subject: [PATCH 18/18] test(databases): drop HTTP create unit test Keep transactionId create coverage in the existing e2e txn suites. Co-authored-by: Jake Barnby --- .../Http/CreateTransactionIdTest.php | 71 ------------------- 1 file changed, 71 deletions(-) delete mode 100644 tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php diff --git a/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php b/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php deleted file mode 100644 index 8d9890e9594..00000000000 --- a/tests/unit/Platform/Modules/Databases/Http/CreateTransactionIdTest.php +++ /dev/null @@ -1,71 +0,0 @@ - $expected - */ - #[DataProvider('createRoutes')] - public function testCreateSdkMethodsExposeTransactionId(Route $route, array $expected): void - { - $methods = $route->getLabel('sdk'); - $this->assertIsArray($methods); - - $names = []; - foreach ($methods as $method) { - $this->assertInstanceOf(Method::class, $method); - $names[$method->getMethodName()] = \array_map( - static fn (Parameter $parameter): string => $parameter->getName(), - $method->getParameters(), - ); - } - - foreach ($expected as $methodName) { - $this->assertArrayHasKey($methodName, $names); - $this->assertContains( - 'transactionId', - $names[$methodName], - $route->getPath() . ' ' . $methodName . ' must expose transactionId', - ); - } - } - - /** - * @return array}> - */ - public static function createRoutes(): array - { - Method::$processed = []; - Method::$errors = []; - - return [ - 'documentsdb' => [ - new DocumentsDBCreate(), - ['createDocument', 'createDocuments'], - ], - 'vectorsdb' => [ - new VectorsDBCreate(), - ['createDocument', 'createDocuments'], - ], - ]; - } -}