diff --git a/.env b/.env index 3ff3672e3dc..6fcb35a3159 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= _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= _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..4b69212e75b 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. 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' => '', + '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. Defaults to https://gitlab.com when unset.', + 'introduction' => '2.0.0', + 'default' => '', + '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/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) { diff --git a/docker-compose.yml b/docker-compose.yml index 9623a3eb089..1aa6df871c1 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..fd2464b4ff0 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 { @@ -230,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 */ @@ -239,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; } @@ -255,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, '/'); } } 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/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), ] ) ]) 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 ); diff --git a/src/Appwrite/SDK/Specification/Format/OpenAPI3.php b/src/Appwrite/SDK/Specification/Format/OpenAPI3.php index 1ee1babc968..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,6 +943,13 @@ public function parse(): array } break; default: + 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/src/Appwrite/Utopia/Database/Attribute.php b/src/Appwrite/Utopia/Database/Attribute.php index b4dba3433c1..61b3dc18954 100644 --- a/src/Appwrite/Utopia/Database/Attribute.php +++ b/src/Appwrite/Utopia/Database/Attribute.php @@ -22,6 +22,10 @@ class Attribute Database::VAR_TEXT => 65535, Database::VAR_MEDIUMTEXT => 16777215, Database::VAR_LONGTEXT => 2147483647, + // 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, ]; /** @@ -91,6 +95,18 @@ public static function resolve(array $attribute): array $size = self::FORMAT_SIZES[$format] ?? $size; } + 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; + $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/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/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index bd0808fd697..39b2bb29ffa 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -1223,6 +1223,177 @@ 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], + // 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], + ], + ]); + + $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['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']); + $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, + '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, [ + $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/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index a49b9cf25bf..de9603611d0 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']); } /** @@ -2668,6 +2709,128 @@ public function testBulkDelete(): void $this->assertEquals(0, $response['body']['total']); } + /** + * Reject bulkUpdate/bulkDelete with decoded query arrays. + */ + public function testCreateBulkOperationsWithArrayQueries(): void + { + // Test for FAILURE + $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'], + ], + ], + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('general_argument_invalid', $response['body']['type']); + + $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' => 'bulkDelete', + 'data' => [ + 'queries' => [Query::equal('category', ['drop'])->toArray()], + ], + ], + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + $this->assertEquals('general_argument_invalid', $response['body']['type']); + } + /** * Test multiple single route operations in one transaction */ 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/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; + } } diff --git a/tests/unit/Utopia/Database/Validator/AttributesTest.php b/tests/unit/Utopia/Database/Validator/AttributesTest.php index 6c02e5bbe79..7bb46d9fa6b 100644 --- a/tests/unit/Utopia/Database/Validator/AttributesTest.php +++ b/tests/unit/Utopia/Database/Validator/AttributesTest.php @@ -120,6 +120,113 @@ 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]) + ); + + $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 + { + $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