Compare commits

..
Author SHA1 Message Date
Eldad A. FuxandGitHub a5fa09b4ce Merge pull request #11185 from Shivangisharma4/doc-fix-typos-contributing
doc: Fix typos in CONTRIBUTING.md
2026-02-27 20:42:57 +01:00
10e2edac90 Update CONTRIBUTING.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 14:03:11 +05:30
Shivangisharma4 340081a67d doc: Fix typos in CONTRIBUTING.md 2026-01-24 13:58:20 +05:30
Steven NguyenandGitHub f0191202a0 Merge pull request #10342 from tejas-raskar/doc-10339-fix-inconsistent-dir-structure-in-contribution-docs
docs: update the directory structure in CONTRIBUTING.md
2026-01-02 15:59:24 -08:00
Steven NguyenandGitHub 44ec8de6bf Merge pull request #10946 from appwrite/1.8.x
Sync 1.8.x into main
2025-12-18 17:06:43 -08:00
Matej BačoandGitHub 9fb1aaadaf Merge pull request #10984 from appwrite/fix-devkey-scopes
Fix: Add scopes for devkeys
2025-12-18 17:26:15 +01:00
Torsten DittmannandGitHub d9ac4f2f2d Merge pull request #10986 from appwrite/fix-oauth-verified-emails 2025-12-18 19:04:56 +04:00
e61f3c7a42 Update phpunit.xml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 18:03:02 +04:00
Jake BarnbyandGitHub 1b6d51a116 Merge pull request #10987 from appwrite/fix-auth
Fix auth calls
2025-12-18 12:56:58 +00:00
Jake Barnby 0058e88217 Fix auth calls 2025-12-19 01:53:05 +13:00
Torsten Dittmann 81b4065921 Fix identity connecting
- Add MockUnverified OAuth2 provider config
- Add /v1/mock/tests/general/oauth2/user-unverified endpoint
- Add MockUnverified class for unverified OAuth2 flow
- Update Mock::isEmailVerified to respect user['verified'] flag
- Add end-to-end tests for linking unverified and verified OAuth2 users
- Enable stopOnFailure in phpunit.xml
2025-12-18 16:44:04 +04:00
Torsten Dittmann d094d6a081 Remove OAuth email verification comments 2025-12-18 16:00:41 +04:00
Torsten Dittmann bae194e866 Link account by email during OAuth 2025-12-18 15:59:33 +04:00
Torsten Dittmann 144e88452e Use general bad request for unverified OAuth email 2025-12-18 15:49:59 +04:00
Torsten Dittmann 89980b1f0e Enforce email verification when linking OAuth2 2025-12-18 15:46:03 +04:00
Matej Bačo 7dea3f19a9 Add scopes for devkeys 2025-12-18 12:31:00 +01:00
Tejas Raskar 80ad740d36 docs: update the directory structure in CONTRIBUTING.md 2025-08-20 14:34:43 +05:30
20 changed files with 356 additions and 1990 deletions
+57 -35
View File
@@ -111,7 +111,7 @@ $ git push origin [name_of_your_new_branch]
## Setup From Source
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and create run the docker-compose stack.
To set up a working **development environment**, just fork the project git repository and install the backend and frontend dependencies using the proper package manager and run the docker-compose stack.
> If you just want to install Appwrite for day-to-day use and not as a contributor, you can reference the [installation guide](https://github.com/appwrite/appwrite#installation), the [getting started guide](https://appwrite.io/docs/quick-starts), or the main [README](README.md) file.
@@ -173,12 +173,12 @@ Learn more at our [Technology Stack](#technology-stack) section.
- [MVVM](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel) - Appwrite console architecture
##### Container Namespace Conventions
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on it's intended use.
To keep our services easy to understand within Docker we follow a naming convention for all our containers depending on its intended use.
`appwrite-worker-X` - Workers (`src/Appwrite/Platform/Workers/*`)
`appwrite-task-X` - Tasks (`src/Appwrite/Platform/Tasks/*`)
Other containes should be named the same as their service, for example `redis` should just be called `redis`.
Other containers should be named the same as their service, for example `redis` should just be called `redis`.
##### Security
@@ -189,7 +189,7 @@ Other containes should be named the same as their service, for example `redis` s
## Modules
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesnt contain all product's features such as workers or tasks. While there might still be some occurances of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
As Appwrite grows, we noticed approach of having all service endpoints in `app/controllers/api/[service].php` is not maintainable. Not only it creates massive files, it also doesn't contain all product's features such as workers or tasks. While there might still be some occurrences of those controller files, we avoid it in all new development, and gradually migrate existing controllers to **HTTP modules**.
### HTTP Endpoints
@@ -204,7 +204,7 @@ Tips and tricks:
1. If endpoint doesn't have resource, use service name as resource name too
> Example: `Modules/Sites/Http/Sites/Get.php`
2. If there are multiple resources, use then all in folder structure
2. If there are multiple resources, use them all in folder structure
> Example: `Modules/Sites/Http/Deployments/Builds/Create.php`
3. Action can only be `Get`, `Create`, `Update`, `Delete` or `XList`
@@ -222,51 +222,73 @@ Appwrite's current structure is a combination of both [Monolithic](https://en.wi
```bash
.
├── app # Main application
│ ├── assets
│ │ ├── dbip
│ │ ├── fonts
│ │ └── security
│ ├── config # Config files
│ │ ├── avatars
│ │ ├── collections
│ │ ├── locale
│ │ ├── specs
│ │ ├── storage
│ │ └── templates
│ ├── controllers # API & dashboard controllers
│ │ ├── api
│ │ ├── shared
│ │ └── web
│ ├── db # DB schemas
├── sdks # SDKs generated copies (used for generating code examples)
── tasks # Server CLI commands
├── views # HTML server-side templates
└── workers # Background workers
│ ├── init # DB schemas
│ └── database
── views # HTML server-side templates
├── general
└── install
├── bin # Server executables (tasks & workers)
├── docker # Docker related resources and configs
├── dev # Debugger config
├── docs # Docs and tutorials
│ ├── examples
│ ├── lists
│ ├── references
│ ├── sdks
│ ├── services
│ ├── specs
│ └── tutorials
├── public # Public files
│ ├── dist
│ ├── fonts
│ ├── images
│ ├── scripts
── styles
├── src # Supporting libraries (each lib has one role, common libs are released as individual projects)
│ └── Appwrite
├── Auth
├── Detector
├── Docker
| ├── DSN
├── Event
├── Extend
├── GraphQL
├── Messaging
├── Migration
├── Network
├── OpenSSL
├── Promises
├── Specification
├── Task
├── Template
├── URL
── Utopia
│ ├── sdk-console
── sdk-project
│ └── sdk-web
├── src # Supporting libraries (each lib has one role, common libs are released as
│ ├── Appwrite
├── Auth
├── Certificates
├── Deletes
├── Detector
├── Docker
├── Event
├── Extend
├── Functions/Validator
├── GraphQL
├── Hooks
├── Messaging
├── Migration
├── Network
├── OpenSSL
├── Platform
── Promises
│ │ ├── PubSub
│ │ ├── SDK
│ │ ├── Task/Validator
│ │ ├── Template
│ │ ├── Transformation
│ │ ├── URL
│ │ ├── Utopia
│ │ └── Vcs
│ └── Executor
└── tests # End to end & unit tests
├── benchmarks
├── e2e
├── extensions
├── resources
└── unit
```
@@ -395,7 +417,7 @@ These are the current metrics we collect usage stats for:
> Note: The curly brackets in the metric name represents a template and is replaced with a value when the metric is processed.
Metrics are collected within 3 scopes Daily, monthly, an infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
Metrics are collected within 3 scopes Daily, monthly, and infinity. Adding new usage metric in order to aggregate usage stats is very simple, but very much dependent on where do you want to collect
statistics ,via API or via background worker. For both cases you will need to add a `const` variable in `app/init.php` under the usage metrics list using the naming convention `METRIC_<RESOURCE_NAME>` as shown below.
```php
@@ -661,7 +683,7 @@ docker compose exec redis redis-cli FLUSHALL
## Using preview domains locally
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud woekspaces such as Gitpod or GitHub Codespaces.
Appwrite Functions are automatically given a domain you can visit to execute the function. This domain has format `[SOMETHING].functions.localhost` unless you changed `_APP_DOMAIN_FUNCTIONS` environment variable. This default value works great when running Appwrite locally, but it can be impossible to use preview domains with Cloud workspaces such as Gitpod or GitHub Codespaces.
To use preview domains on Cloud workspaces, you can visit hostname provided by them, and supply function's preview domain as URL parameter:
+11
View File
@@ -462,4 +462,15 @@ return [
'mock' => true,
'class' => 'Appwrite\\Auth\\OAuth2\\Mock',
],
'mock-unverified' => [
'name' => 'MockUnverified',
'developers' => 'https://appwrite.io',
'icon' => 'icon-appwrite',
'enabled' => true,
'sandbox' => false,
'form' => false,
'beta' => false,
'mock' => true,
'class' => 'Appwrite\\Auth\\OAuth2\\MockUnverified',
],
];
+2
View File
@@ -58,6 +58,8 @@ $admins = [
'projects.write',
'keys.read',
'keys.write',
'devKeys.read',
'devKeys.write',
'webhooks.read',
'webhooks.write',
'locale.read',
+17 -13
View File
@@ -1639,9 +1639,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$failureRedirect(Exception::USER_UNAUTHORIZED, 'OAuth provider failed to return email.');
}
/**
* Is verified is not used yet, since we don't know after an account is created anymore if it was verified or not.
*/
$isVerified = $oauth2->isEmailVerified($accessToken);
$identity = $dbForProject->findOne('identities', [
@@ -1653,16 +1650,32 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$user = $dbForProject->getDocument('users', $identity->getAttribute('userId'));
}
// If user is not found, check if there is an identity with the same provider user ID
// If user is not found, check if there is a user with the same email
if ($user === false || $user->isEmpty()) {
$userWithEmail = $dbForProject->findOne('users', [
Query::equal('email', [$email]),
]);
if (!$userWithEmail->isEmpty()) {
if (!$isVerified) {
$failureRedirect(Exception::GENERAL_BAD_REQUEST);
}
$user->setAttributes($userWithEmail->getArrayCopy());
}
}
// If user is not found, check if there is an identity with the same email
if ($user === false || $user->isEmpty()) {
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
if (!$identityWithMatchingEmail->isEmpty()) {
if (!$isVerified) {
$failureRedirect(Exception::GENERAL_BAD_REQUEST);
}
$user->setAttributes($dbForProject->getDocument('users', $identityWithMatchingEmail->getAttribute('userId'))->getArrayCopy());
}
}
if ($user === false || $user->isEmpty()) { // Last option -> create the user
$limit = $project->getAttribute('auths', [])['limit'] ?? 0;
@@ -1674,14 +1687,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
}
}
// Makes sure this email is not already used in another identity
$identityWithMatchingEmail = $dbForProject->findOne('identities', [
Query::equal('providerEmail', [$email]),
]);
if (!$identityWithMatchingEmail->isEmpty()) {
$failureRedirect(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
}
try {
$emailCanonical = new Email($email);
} catch (Throwable) {
@@ -1735,7 +1740,6 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'providerType' => MESSAGE_TYPE_EMAIL,
'identifier' => $email,
]));
} catch (Duplicate) {
$failureRedirect(Exception::USER_ALREADY_EXISTS);
}
+4 -3
View File
@@ -1034,7 +1034,8 @@ App::init()
->inject('dbForPlatform')
->inject('queueForCertificates')
->inject('platform')
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform) {
->inject('authorization')
->action(function (Request $request, Document $console, Database $dbForPlatform, Certificate $queueForCertificates, array $platform, Authorization $authorization) {
$hostname = $request->getHostname();
$cache = Config::getParam('hostnames', []);
$platformHostnames = $platform['hostnames'] ?? [];
@@ -1065,7 +1066,7 @@ App::init()
}
// 4. Check/create rule (requires DB access)
Authorization::disable();
$authorization->disable();
try {
// TODO: (@Meldiron) Remove after 1.7.x migration
$isMd5 = System::getEnv('_APP_RULES_FORMAT') === 'md5';
@@ -1121,7 +1122,7 @@ App::init()
} finally {
$cache[$domain->get()] = true;
Config::setParam('hostnames', $cache);
Authorization::reset();
$authorization->reset();
}
});
+22
View File
@@ -117,6 +117,28 @@ App::get('/v1/mock/tests/general/oauth2/user')
'id' => 1,
'name' => 'User Name',
'email' => 'useroauth@localhost.test',
'verified' => true,
]);
});
App::get('/v1/mock/tests/general/oauth2/user-unverified')
->desc('OAuth2 User Unverified')
->groups(['mock'])
->label('scope', 'public')
->label('docs', false)
->param('token', '', new Text(100), 'OAuth2 Access Token.')
->inject('response')
->action(function (string $token, Response $response) {
if ($token != '123456') {
throw new Exception(Exception::GENERAL_MOCK, 'Invalid token');
}
$response->json([
'id' => 2,
'name' => 'User Name Unverified',
'email' => 'useroauthunverified@localhost.test',
'verified' => false,
]);
});
+3 -1
View File
@@ -130,7 +130,9 @@ class Mock extends OAuth2
*/
public function isEmailVerified(string $accessToken): bool
{
return true;
$user = $this->getUser($accessToken);
return $user['verified'] ?? true;
}
/**
@@ -0,0 +1,30 @@
<?php
namespace Appwrite\Auth\OAuth2;
class MockUnverified extends Mock
{
/**
* @return string
*/
public function getName(): string
{
return 'mock-unverified';
}
/**
* @param string $accessToken
*
* @return array
*/
protected function getUser(string $accessToken): array
{
if (empty($this->user)) {
$user = $this->request('GET', 'http://localhost/' . $this->version . '/mock/tests/general/oauth2/user-unverified?token=' . \urlencode($accessToken));
$this->user = \json_decode($user, true);
}
return $this->user;
}
}
@@ -124,18 +124,9 @@ class Create extends Action
$collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence();
$databaseKey = 'database_' . $database->getSequence();
// Map 'float' to 'double' (Database::VAR_FLOAT) before validation
$attributes = array_map(function ($attr) {
if (isset($attr['type']) && $attr['type'] === 'float') {
$attr['type'] = Database::VAR_FLOAT;
}
return $attr;
}, $attributes);
$attributesValidator = new AttributesValidator(
APP_LIMIT_ARRAY_PARAMS_SIZE,
$dbForProject->getAdapter()->getSupportForSpatialAttributes(),
$this->isCollectionsAPI() ? AttributesValidator::TYPE_LEGACY : AttributesValidator::TYPE_TABLESDB
$dbForProject->getAdapter()->getSupportForSpatialAttributes()
);
if (!$attributesValidator->isValid($attributes)) {
@@ -143,11 +134,10 @@ class Create extends Action
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $attributesValidator->getDescription());
}
$structureTerm = $this->isCollectionsAPI() ? 'attributes' : 'columns';
foreach ($attributes as $attribute) {
if (($attribute['type'] ?? '') === Database::VAR_RELATIONSHIP) {
$dbForProject->deleteDocument($databaseKey, $collection->getId());
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, "Relationship {$structureTerm} cannot be created inline. Use the create relationship endpoint instead.");
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Relationship attributes cannot be created inline. Use the create relationship endpoint instead.');
}
}
@@ -165,10 +155,7 @@ class Create extends Action
}
// Validate indexes
$indexesValidator = new IndexesValidator(
$dbForProject->getLimitForIndexes(),
$this->isCollectionsAPI() ? IndexesValidator::TYPE_LEGACY : IndexesValidator::TYPE_TABLESDB
);
$indexesValidator = new IndexesValidator($dbForProject->getLimitForIndexes());
if (!$indexesValidator->isValid($indexes)) {
$dbForProject->deleteDocument($databaseKey, $collection->getId());
throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $indexesValidator->getDescription());
@@ -199,9 +186,7 @@ class Create extends Action
$dbForProject->getAdapter()->getSupportForVectors(),
$dbForProject->getAdapter()->getSupportForAttributes(),
$dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(),
$dbForProject->getAdapter()->getSupportForIdenticalIndexes(),
$dbForProject->getAdapter()->getSupportForObject(),
$dbForProject->getAdapter()->getSupportForIdenticalIndexes()
);
foreach ($collectionIndexes as $indexDoc) {
@@ -252,12 +237,6 @@ class Create extends Action
$dbForProject->purgeCachedDocument('database_' . $database->getSequence(), $collection->getId());
$dbForProject->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence());
// Set attributes and indexes on the collection document for the response
// Use the full database documents for attributes (they have status and other fields needed for response models)
// Use the simpler collection documents for indexes (they have the basic fields without internal tracking)
$collection->setAttribute('attributes', $attributeDocuments);
$collection->setAttribute('indexes', $collectionIndexes);
$queueForEvents
->setContext('database', $database)
->setParam('databaseId', $databaseId)
@@ -34,7 +34,7 @@ class Create extends Action
->setHttpPath('/v1/projects/:projectId/dev-keys')
->desc('Create dev key')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('scope', 'devKeys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'devKeys',
@@ -28,7 +28,7 @@ class Delete extends Action
->setHttpPath('/v1/projects/:projectId/dev-keys/:keyId')
->desc('Delete dev key')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('scope', 'devKeys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'devKeys',
@@ -28,7 +28,7 @@ class Get extends Action
->setHttpPath('/v1/projects/:projectId/dev-keys/:keyId')
->desc('Get dev key')
->groups(['api', 'projects'])
->label('scope', 'projects.read')
->label('scope', 'devKeys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'devKeys',
@@ -29,7 +29,7 @@ class Update extends Action
->setHttpPath('/v1/projects/:projectId/dev-keys/:keyId')
->desc('Update dev key')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('scope', 'devKeys.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'devKeys',
@@ -32,7 +32,7 @@ class XList extends Action
->setHttpPath('/v1/projects/:projectId/dev-keys')
->desc('List dev keys')
->groups(['api', 'projects'])
->label('scope', 'projects.read')
->label('scope', 'devKeys.read')
->label('sdk', new Method(
namespace: 'projects',
group: 'devKeys',
@@ -14,18 +14,9 @@ use Utopia\Validator\URL;
class Attributes extends Validator
{
public const TYPE_LEGACY = 'legacy';
public const TYPE_TABLESDB = 'tablesdb';
protected int $maxAttributes;
protected string $message = 'Invalid attributes';
/**
* @var string The term to use in error messages (attributes/columns)
*/
protected string $termPlural;
protected string $termSingular;
/**
* @var array<string> Supported attribute types
*/
@@ -54,23 +45,12 @@ class Attributes extends Validator
/**
* @param int $maxAttributes Maximum number of attributes allowed
* @param bool $supportForSpatialAttributes Whether DB supports spatial attributes
* @param string $type The API type context ('legacy' for attributes, 'tablesdb' for columns)
*/
public function __construct(
int $maxAttributes = APP_LIMIT_ARRAY_PARAMS_SIZE,
protected bool $supportForSpatialAttributes = true,
string $type = self::TYPE_LEGACY,
) {
$this->maxAttributes = $maxAttributes;
// Set terminology based on API type
if ($type === self::TYPE_TABLESDB) {
$this->termPlural = 'Columns';
$this->termSingular = 'Column';
} else {
$this->termPlural = 'Attributes';
$this->termSingular = 'Attribute';
}
}
/**
@@ -93,16 +73,13 @@ class Attributes extends Validator
*/
public function isValid($value): bool
{
$termLower = \strtolower($this->termSingular);
$termPluralLower = \strtolower($this->termPlural);
if (!\is_array($value)) {
$this->message = "{$this->termPlural} must be an array";
$this->message = 'Attributes must be an array';
return false;
}
if (\count($value) > $this->maxAttributes) {
$this->message = "Maximum of {$this->maxAttributes} {$termPluralLower} allowed";
$this->message = 'Maximum of ' . $this->maxAttributes . ' attributes allowed';
return false;
}
@@ -111,30 +88,30 @@ class Attributes extends Validator
foreach ($value as $index => $attribute) {
if (!\is_array($attribute)) {
$this->message = "{$this->termSingular} at index $index must be an object";
$this->message = "Attribute at index $index must be an object";
return false;
}
// Validate required fields
if (!isset($attribute['key'])) {
$this->message = "{$this->termSingular} at index $index is missing required field 'key'";
$this->message = "Attribute at index $index is missing required field 'key'";
return false;
}
if (!isset($attribute['type'])) {
$this->message = "{$this->termSingular} at index $index is missing required field 'type'";
$this->message = "Attribute at index $index is missing required field 'type'";
return false;
}
// Validate key
if (!$keyValidator->isValid($attribute['key'])) {
$this->message = "Invalid key for {$termLower} at index $index: " . $keyValidator->getDescription();
$this->message = "Invalid key for attribute at index $index: " . $keyValidator->getDescription();
return false;
}
// Check for duplicate keys
if (in_array($attribute['key'], $keys)) {
$this->message = "Duplicate {$termLower} key: " . $attribute['key'];
$this->message = "Duplicate attribute key: " . $attribute['key'];
return false;
}
$keys[] = $attribute['key'];
@@ -142,26 +119,26 @@ class Attributes extends Validator
// Check for reserved keys
$reservedKeys = ['$id', '$createdAt', '$updatedAt', '$permissions', '$collection'];
if (\in_array($attribute['key'], $reservedKeys)) {
$this->message = "{$this->termSingular} key '" . $attribute['key'] . "' is reserved and cannot be used";
$this->message = "Attribute key '" . $attribute['key'] . "' is reserved and cannot be used";
return false;
}
// Validate type
if (!\in_array($attribute['type'], $this->supportedTypes)) {
$this->message = "Invalid type for {$termLower} '" . $attribute['key'] . "': " . $attribute['type'];
$this->message = "Invalid type for attribute '" . $attribute['key'] . "': " . $attribute['type'];
return false;
}
// Validate spatial type support
if (\in_array($attribute['type'], Database::SPATIAL_TYPES) && !$this->supportForSpatialAttributes) {
$this->message = "Spatial {$termPluralLower} are not supported by the current database";
$this->message = "Spatial attributes are not supported by the current database";
return false;
}
// Validate size for string types
if ($attribute['type'] === Database::VAR_STRING) {
if (!isset($attribute['size']) || !is_int($attribute['size']) || $attribute['size'] < 1 || $attribute['size'] > APP_DATABASE_ATTRIBUTE_STRING_MAX_LENGTH) {
$this->message = "Invalid or missing size for string {$termLower} '" . $attribute['key'] . "'. Size must be between 1 and " . APP_DATABASE_ATTRIBUTE_STRING_MAX_LENGTH;
$this->message = "Invalid or missing size for string attribute '" . $attribute['key'] . "'. Size must be between 1 and " . APP_DATABASE_ATTRIBUTE_STRING_MAX_LENGTH;
return false;
}
}
@@ -170,61 +147,61 @@ class Attributes extends Validator
if (isset($attribute['format']) && $attribute['format'] !== '') {
// Format is only allowed for string type
if ($attribute['type'] !== Database::VAR_STRING) {
$this->message = "Format is only allowed for string type for {$termLower} '" . $attribute['key'] . "'";
$this->message = "Format is only allowed for string type for attribute '" . $attribute['key'] . "'";
return false;
}
if (!in_array($attribute['format'], $this->supportedFormats)) {
$this->message = "Invalid format for {$termLower} '" . $attribute['key'] . "': " . $attribute['format'];
$this->message = "Invalid format for attribute '" . $attribute['key'] . "': " . $attribute['format'];
return false;
}
}
// Validate required field if provided
if (isset($attribute['required']) && !is_bool($attribute['required'])) {
$this->message = "Invalid 'required' value for {$termLower} '" . $attribute['key'] . "': must be a boolean";
$this->message = "Invalid 'required' value for attribute '" . $attribute['key'] . "': must be a boolean";
return false;
}
// Validate array field if provided
if (isset($attribute['array']) && !is_bool($attribute['array'])) {
$this->message = "Invalid 'array' value for {$termLower} '" . $attribute['key'] . "': must be a boolean";
$this->message = "Invalid 'array' value for attribute '" . $attribute['key'] . "': must be a boolean";
return false;
}
// Validate signed field if provided
if (isset($attribute['signed']) && !is_bool($attribute['signed'])) {
$this->message = "Invalid 'signed' value for {$termLower} '" . $attribute['key'] . "': must be a boolean";
$this->message = "Invalid 'signed' value for attribute '" . $attribute['key'] . "': must be a boolean";
return false;
}
// Validate signed only for integer/float types
if (isset($attribute['signed']) && !in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "': 'signed' can only be used with integer or float types";
$this->message = "Attribute '" . $attribute['key'] . "': 'signed' can only be used with integer or float types";
return false;
}
// Validate required and default conflict
if (isset($attribute['required']) && $attribute['required'] === true && isset($attribute['default']) && $attribute['default'] !== null) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "' cannot have a default value when required is true";
$this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when required is true";
return false;
}
// Validate array and default conflict
if (isset($attribute['array']) && $attribute['array'] === true && isset($attribute['default']) && $attribute['default'] !== null) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "' cannot have a default value when array is true";
$this->message = "Attribute '" . $attribute['key'] . "' cannot have a default value when array is true";
return false;
}
// Validate min/max range for integer/float
if (isset($attribute['min']) || isset($attribute['max'])) {
if (!in_array($attribute['type'], [Database::VAR_INTEGER, Database::VAR_FLOAT])) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "': min/max can only be used with integer or float types";
$this->message = "Attribute '" . $attribute['key'] . "': min/max can only be used with integer or float types";
return false;
}
// If both are set, validate ordering
if (isset($attribute['min']) && isset($attribute['max']) && $attribute['min'] > $attribute['max']) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "': minimum value must be less than or equal to maximum value";
$this->message = "Attribute '" . $attribute['key'] . "': minimum value must be less than or equal to maximum value";
return false;
}
}
@@ -234,7 +211,7 @@ class Attributes extends Validator
switch ($attribute['type']) {
case Database::VAR_STRING:
if (!is_string($attribute['default'])) {
$this->message = "Default value for string {$termLower} '" . $attribute['key'] . "' must be a string";
$this->message = "Default value for string attribute '" . $attribute['key'] . "' must be a string";
return false;
}
@@ -243,7 +220,7 @@ class Attributes extends Validator
if ($size > 0) {
$textValidator = new Text($size, 0);
if (!$textValidator->isValid($attribute['default'])) {
$this->message = "Default value for {$termLower} '" . $attribute['key'] . "' exceeds maximum size of $size characters";
$this->message = "Default value for attribute '" . $attribute['key'] . "' exceeds maximum size of $size characters";
return false;
}
}
@@ -253,19 +230,19 @@ class Attributes extends Validator
if ($format === APP_DATABASE_ATTRIBUTE_EMAIL) {
$emailValidator = new Email();
if (!$emailValidator->isValid($attribute['default'])) {
$this->message = "Default value for email {$termLower} '" . $attribute['key'] . "' must be a valid email address";
$this->message = "Default value for email attribute '" . $attribute['key'] . "' must be a valid email address";
return false;
}
} elseif ($format === APP_DATABASE_ATTRIBUTE_IP) {
$ipValidator = new IP();
if (!$ipValidator->isValid($attribute['default'])) {
$this->message = "Default value for IP {$termLower} '" . $attribute['key'] . "' must be a valid IP address";
$this->message = "Default value for IP attribute '" . $attribute['key'] . "' must be a valid IP address";
return false;
}
} elseif ($format === APP_DATABASE_ATTRIBUTE_URL) {
$urlValidator = new URL();
if (!$urlValidator->isValid($attribute['default'])) {
$this->message = "Default value for URL {$termLower} '" . $attribute['key'] . "' must be a valid URL";
$this->message = "Default value for URL attribute '" . $attribute['key'] . "' must be a valid URL";
return false;
}
}
@@ -273,7 +250,7 @@ class Attributes extends Validator
case Database::VAR_INTEGER:
if (!is_int($attribute['default'])) {
$this->message = "Default value for integer {$termLower} '" . $attribute['key'] . "' must be an integer";
$this->message = "Default value for integer attribute '" . $attribute['key'] . "' must be an integer";
return false;
}
// Validate within range if min/max specified
@@ -282,7 +259,7 @@ class Attributes extends Validator
$max = $attribute['max'] ?? \PHP_INT_MAX;
$rangeValidator = new Range($min, $max, Database::VAR_INTEGER);
if (!$rangeValidator->isValid($attribute['default'])) {
$this->message = "Default value for integer {$termLower} '" . $attribute['key'] . "' must be between $min and $max";
$this->message = "Default value for integer attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
}
}
@@ -290,7 +267,7 @@ class Attributes extends Validator
case Database::VAR_FLOAT:
if (!is_float($attribute['default']) && !is_int($attribute['default'])) {
$this->message = "Default value for float {$termLower} '" . $attribute['key'] . "' must be a number";
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be a number";
return false;
}
// Validate within range if min/max specified
@@ -299,7 +276,7 @@ class Attributes extends Validator
$max = $attribute['max'] ?? \PHP_FLOAT_MAX;
$rangeValidator = new Range($min, $max, Database::VAR_FLOAT);
if (!$rangeValidator->isValid((float)$attribute['default'])) {
$this->message = "Default value for float {$termLower} '" . $attribute['key'] . "' must be between $min and $max";
$this->message = "Default value for float attribute '" . $attribute['key'] . "' must be between $min and $max";
return false;
}
}
@@ -307,20 +284,20 @@ class Attributes extends Validator
case Database::VAR_BOOLEAN:
if (!is_bool($attribute['default'])) {
$this->message = "Default value for boolean {$termLower} '" . $attribute['key'] . "' must be a boolean";
$this->message = "Default value for boolean attribute '" . $attribute['key'] . "' must be a boolean";
return false;
}
break;
case Database::VAR_DATETIME:
if (!is_string($attribute['default'])) {
$this->message = "Default value for datetime {$termLower} '" . $attribute['key'] . "' must be a string in ISO 8601 format";
$this->message = "Default value for datetime attribute '" . $attribute['key'] . "' must be a string in ISO 8601 format";
return false;
}
// Basic datetime format validation
$datetimeValidator = new DatetimeValidator();
if (!$datetimeValidator->isValid($attribute['default'])) {
$this->message = "Default value for datetime {$termLower} '" . $attribute['key'] . "' must be in valid ISO 8601 format";
$this->message = "Default value for datetime attribute '" . $attribute['key'] . "' must be in valid ISO 8601 format";
return false;
}
break;
@@ -330,18 +307,18 @@ class Attributes extends Validator
// Validate enum elements if format is enum
if (isset($attribute['format']) && $attribute['format'] === APP_DATABASE_ATTRIBUTE_ENUM) {
if (!isset($attribute['elements']) || !is_array($attribute['elements']) || empty($attribute['elements'])) {
$this->message = "{$this->termSingular} '" . $attribute['key'] . "' with enum format must have 'elements' array";
$this->message = "Attribute '" . $attribute['key'] . "' with enum format must have 'elements' array";
return false;
}
// Validate each enum element
foreach ($attribute['elements'] as $elementIndex => $element) {
if (!is_string($element) || empty($element)) {
$this->message = "Enum element at index $elementIndex for {$termLower} '" . $attribute['key'] . "' must be a non-empty string";
$this->message = "Enum element at index $elementIndex for attribute '" . $attribute['key'] . "' must be a non-empty string";
return false;
}
if (strlen($element) > Database::LENGTH_KEY) {
$this->message = "Enum element at index $elementIndex for {$termLower} '" . $attribute['key'] . "' exceeds maximum length of " . Database::LENGTH_KEY . " characters";
$this->message = "Enum element at index $elementIndex for attribute '" . $attribute['key'] . "' exceeds maximum length of " . Database::LENGTH_KEY . " characters";
return false;
}
}
@@ -349,7 +326,7 @@ class Attributes extends Validator
// Validate default exists in elements
if (isset($attribute['default']) && $attribute['default'] !== null) {
if (!in_array($attribute['default'], $attribute['elements'], true)) {
$this->message = "Default value for enum {$termLower} '" . $attribute['key'] . "' must be one of the provided elements";
$this->message = "Default value for enum attribute '" . $attribute['key'] . "' must be one of the provided elements";
return false;
}
}
@@ -8,16 +8,8 @@ use Utopia\Validator;
class Indexes extends Validator
{
public const TYPE_LEGACY = 'legacy';
public const TYPE_TABLESDB = 'tablesdb';
protected string $message = 'Invalid indexes';
/**
* @var string The term to use in error messages for attributes/columns
*/
protected string $attributeTerm;
/**
* @var array<string> Supported index types
*/
@@ -38,14 +30,10 @@ class Indexes extends Validator
/**
* @param int $maxIndexes Maximum number of indexes allowed
* @param string $type The API type context ('legacy' for attributes, 'tablesdb' for columns)
*/
public function __construct(
protected int $maxIndexes = APP_LIMIT_ARRAY_PARAMS_SIZE,
string $type = self::TYPE_LEGACY,
) {
// Set terminology based on API type
$this->attributeTerm = ($type === self::TYPE_TABLESDB) ? 'column' : 'attribute';
}
/**
@@ -96,21 +84,11 @@ class Indexes extends Validator
return false;
}
$attributesFieldName = ($this->attributeTerm === 'column') ? 'columns' : 'attributes';
if (!isset($index['attributes']) && !isset($index['columns'])) {
$this->message = "Index at position $i is missing required field '{$attributesFieldName}' (must be an array)";
if (!isset($index['attributes']) || !is_array($index['attributes'])) {
$this->message = "Index at position $i is missing required field 'attributes' (must be an array)";
return false;
}
// Support both 'attributes' and 'columns' field names for TablesDB compatibility
$indexAttributes = $index['attributes'] ?? $index['columns'] ?? null;
if (!is_array($indexAttributes)) {
$this->message = "Index at position $i is missing required field '{$attributesFieldName}' (must be an array)";
return false;
}
// Normalize to 'attributes' for internal processing
$index['attributes'] = $indexAttributes;
// Validate key format
if (!$keyValidator->isValid($index['key'])) {
$this->message = "Invalid key for index at position $i: " . $keyValidator->getDescription();
@@ -132,12 +110,12 @@ class Indexes extends Validator
// Validate attributes array
if (empty($index['attributes'])) {
$this->message = "Index '" . $index['key'] . "' must have at least one {$this->attributeTerm}";
$this->message = "Index '" . $index['key'] . "' must have at least one attribute";
return false;
}
if (count($index['attributes']) > APP_LIMIT_ARRAY_PARAMS_SIZE) {
$this->message = "Index '" . $index['key'] . "' cannot have more than " . APP_LIMIT_ARRAY_PARAMS_SIZE . " {$this->attributeTerm}s";
$this->message = "Index '" . $index['key'] . "' cannot have more than " . APP_LIMIT_ARRAY_PARAMS_SIZE . " attributes";
return false;
}
@@ -145,11 +123,11 @@ class Indexes extends Validator
$systemAttrs = ['$id', '$createdAt', '$updatedAt'];
foreach ($index['attributes'] as $attrIndex => $attr) {
if (!is_string($attr)) {
$this->message = "Invalid {$this->attributeTerm} at position $attrIndex in index '" . $index['key'] . "': must be a string";
$this->message = "Invalid attribute at position $attrIndex in index '" . $index['key'] . "': must be a string";
return false;
}
if (!$keyValidator->isValid($attr) && !in_array($attr, $systemAttrs)) {
$this->message = "Invalid {$this->attributeTerm} name '$attr' in index '" . $index['key'] . "'";
$this->message = "Invalid attribute name '$attr' in index '" . $index['key'] . "'";
return false;
}
}
@@ -164,7 +142,7 @@ class Indexes extends Validator
}
if (count($index['orders']) !== $attrCount) {
$this->message = "Index '" . $index['key'] . "': orders array length (" . count($index['orders']) . ") must match {$attributesFieldName} array length ($attrCount)";
$this->message = "Index '" . $index['key'] . "': orders array length (" . count($index['orders']) . ") must match attributes array length ($attrCount)";
return false;
}
@@ -184,7 +162,7 @@ class Indexes extends Validator
}
if (count($index['lengths']) !== $attrCount) {
$this->message = "Index '" . $index['key'] . "': lengths array length (" . count($index['lengths']) . ") must match {$attributesFieldName} array length ($attrCount)";
$this->message = "Index '" . $index['key'] . "': lengths array length (" . count($index['lengths']) . ") must match attributes array length ($attrCount)";
return false;
}
@@ -95,9 +95,12 @@ class ColumnIndex extends Model
public function filter(Document $document): Document
{
$attributes = $document->getAttribute('attributes', []);
$document->setAttribute('columns', $attributes);
$columns = $document->getAttribute('attributes', []);
$document
->removeAttribute('attributes')
->setAttribute('columns', $columns);
return $document;
}
}
@@ -2183,6 +2183,157 @@ class AccountCustomClientTest extends Scope
$this->assertEquals('tuvwxyz', $response['body']['providerRefreshToken']);
$this->assertNotEquals($initialExpiry, $response['body']['providerAccessTokenExpiry']);
// Clean up - delete the user
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $userId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(204, $response['headers']['status-code']);
return [];
}
public function testOAuthUnverifiedEmailCannotLinkToExistingAccount()
{
$provider = 'mock-unverified';
$appId = '1';
$secret = '123456';
// First, create a user with the same email that the unverified OAuth will try to use
$email = 'useroauthunverified@localhost.test';
$password = 'password';
$response = $this->client->call(Client::METHOD_POST, '/account', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'userId' => ID::unique(),
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$existingUserId = $response['body']['$id'];
// Enable the mock-unverified provider
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'provider' => $provider,
'appId' => $appId,
'secret' => $secret,
'enabled' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Attempt OAuth login with unverified email - should fail because existing user has same email
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertEquals('failure', $response['body']['result']);
// Clean up - delete the user
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(204, $response['headers']['status-code']);
return [];
}
public function testOAuthVerifiedEmailCanLinkToExistingAccount()
{
$provider = 'mock';
$appId = '1';
$secret = '123456';
$email = 'useroauth@localhost.test';
// Create a user with the same email that the verified OAuth will try to use
$response = $this->client->call(Client::METHOD_POST, '/account', [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], [
'userId' => ID::unique(),
'email' => $email,
'password' => 'password',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$existingUserId = $response['body']['$id'];
// Enable the mock provider
$response = $this->client->call(Client::METHOD_PATCH, '/projects/' . $this->getProject()['$id'] . '/oauth2', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => 'console',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
]), [
'provider' => $provider,
'appId' => $appId,
'secret' => $secret,
'enabled' => true,
]);
$this->assertEquals(200, $response['headers']['status-code']);
// Attempt OAuth login with verified email - should succeed and link to existing account
$response = $this->client->call(Client::METHOD_GET, '/account/sessions/oauth2/' . $provider, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]), [
'success' => 'http://localhost/v1/mock/tests/general/oauth2/success',
'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure',
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals('success', $response['body']['result']);
// Verify the OAuth identity was linked to the existing user
$sessionCookieKey = 'a_session_' . $this->getProject()['$id'];
$session = $response['cookies'][$sessionCookieKey];
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $session,
]));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals($existingUserId, $response['body']['$id']);
$this->assertEquals($email, $response['body']['email']);
// Clean up - delete the user
$response = $this->client->call(Client::METHOD_DELETE, '/users/' . $existingUserId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
]));
$this->assertEquals(204, $response['headers']['status-code']);
return [];
}
@@ -86,914 +86,6 @@ trait DatabasesBase
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithStringAttribute(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestStringAttr',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertEquals('TestStringAttr', $collection['body']['name']);
$this->assertCount(1, $collection['body']['attributes']);
$this->assertEquals('title', $collection['body']['attributes'][0]['key']);
$this->assertEquals('string', $collection['body']['attributes'][0]['type']);
$this->assertEquals(255, $collection['body']['attributes'][0]['size']);
$this->assertEquals(true, $collection['body']['attributes'][0]['required']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithMultipleAttributeTypes(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestMultiAttr',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'name',
'type' => 'string',
'size' => 100,
'required' => true,
],
[
'key' => 'age',
'type' => 'integer',
'required' => false,
'default' => 0,
'min' => 0,
'max' => 150,
],
[
'key' => 'score',
'type' => 'float',
'required' => false,
'default' => 0.0,
'min' => 0.0,
'max' => 100.0,
],
[
'key' => 'isActive',
'type' => 'boolean',
'required' => false,
'default' => true,
],
[
'key' => 'createdAt',
'type' => 'datetime',
'required' => false,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertEquals('TestMultiAttr', $collection['body']['name']);
$this->assertCount(5, $collection['body']['attributes']);
// Verify each attribute type
$attributeKeys = array_column($collection['body']['attributes'], 'key');
$this->assertContains('name', $attributeKeys);
$this->assertContains('age', $attributeKeys);
$this->assertContains('score', $attributeKeys);
$this->assertContains('isActive', $attributeKeys);
$this->assertContains('createdAt', $attributeKeys);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithArrayAttribute(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestArrayAttr',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'tags',
'type' => 'string',
'size' => 50,
'required' => false,
'array' => true,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertEquals('TestArrayAttr', $collection['body']['name']);
$this->assertCount(1, $collection['body']['attributes']);
$this->assertEquals('tags', $collection['body']['attributes'][0]['key']);
$this->assertEquals(true, $collection['body']['attributes'][0]['array']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithDefaultValues(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestDefaults',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'status',
'type' => 'string',
'size' => 20,
'required' => false,
'default' => 'pending',
],
[
'key' => 'count',
'type' => 'integer',
'required' => false,
'default' => 0,
],
[
'key' => 'enabled',
'type' => 'boolean',
'required' => false,
'default' => false,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(3, $collection['body']['attributes']);
// Find and verify default values
foreach ($collection['body']['attributes'] as $attr) {
if ($attr['key'] === 'status') {
$this->assertEquals('pending', $attr['default']);
}
if ($attr['key'] === 'count') {
$this->assertEquals(0, $attr['default']);
}
if ($attr['key'] === 'enabled') {
$this->assertEquals(false, $attr['default']);
}
}
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithKeyIndex(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestKeyIndex',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['attributes']);
$this->assertCount(1, $collection['body']['indexes']);
$this->assertEquals('title_idx', $collection['body']['indexes'][0]['key']);
$this->assertEquals('key', $collection['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithUniqueIndex(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestUniqueIndex',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'email',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'email_unique',
'type' => 'unique',
'attributes' => ['email'],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['indexes']);
$this->assertEquals('email_unique', $collection['body']['indexes'][0]['key']);
$this->assertEquals('unique', $collection['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithFulltextIndex(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestFulltextIndex',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'content',
'type' => 'string',
'size' => 10000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'content_fulltext',
'type' => 'fulltext',
'attributes' => ['content'],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['indexes']);
$this->assertEquals('content_fulltext', $collection['body']['indexes'][0]['key']);
$this->assertEquals('fulltext', $collection['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithCompoundIndex(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestCompoundIndex',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'userId',
'type' => 'string',
'size' => 36,
'required' => true,
],
[
'key' => 'createdAt',
'type' => 'datetime',
'required' => true,
],
],
'indexes' => [
[
'key' => 'user_date_idx',
'type' => 'key',
'attributes' => ['userId', 'createdAt'],
'orders' => ['ASC', 'DESC'],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(2, $collection['body']['attributes']);
$this->assertCount(1, $collection['body']['indexes']);
$this->assertEquals('user_date_idx', $collection['body']['indexes'][0]['key']);
$this->assertCount(2, $collection['body']['indexes'][0]['attributes']);
$this->assertEquals(['ASC', 'DESC'], $collection['body']['indexes'][0]['orders']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithMultipleIndexes(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestMultiIndex',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'slug',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'body',
'type' => 'string',
'size' => 50000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
[
'key' => 'slug_unique',
'type' => 'unique',
'attributes' => ['slug'],
],
[
'key' => 'body_fulltext',
'type' => 'fulltext',
'attributes' => ['body'],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(3, $collection['body']['attributes']);
$this->assertCount(3, $collection['body']['indexes']);
$indexKeys = array_column($collection['body']['indexes'], 'key');
$this->assertContains('title_idx', $indexKeys);
$this->assertContains('slug_unique', $indexKeys);
$this->assertContains('body_fulltext', $indexKeys);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithEmptyArrays(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestEmptyArrays',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [],
'indexes' => [],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertEquals('TestEmptyArrays', $collection['body']['name']);
$this->assertCount(0, $collection['body']['attributes']);
$this->assertCount(0, $collection['body']['indexes']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithIntegerMinMax(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestIntMinMax',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'temperature',
'type' => 'integer',
'required' => false,
'min' => -50,
'max' => 50,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['attributes']);
$this->assertEquals('temperature', $collection['body']['attributes'][0]['key']);
$this->assertEquals(-50, $collection['body']['attributes'][0]['min']);
$this->assertEquals(50, $collection['body']['attributes'][0]['max']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithFloatMinMax(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestFloatMinMax',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'rating',
'type' => 'float',
'required' => false,
'min' => 0.0,
'max' => 5.0,
'default' => 0.0,
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['attributes']);
$this->assertEquals('rating', $collection['body']['attributes'][0]['key']);
$this->assertEquals(0.0, $collection['body']['attributes'][0]['min']);
$this->assertEquals(5.0, $collection['body']['attributes'][0]['max']);
$this->assertEquals(0.0, $collection['body']['attributes'][0]['default']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionInvalidAttributeType(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestInvalidType',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'invalid',
'type' => 'invalid_type',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionInvalidIndexType(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestInvalidIndex',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'invalid_idx',
'type' => 'invalid_type',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionMissingAttributeKey(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestMissingKey',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'type' => 'string',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionMissingAttributeType(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestMissingType',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'field',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionStringSizeMissing(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestMissingSize',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'required' => false,
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionIndexOnNonExistentAttribute(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestBadIndexAttr',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'nonexistent_idx',
'type' => 'key',
'attributes' => ['nonexistent_field'],
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionDuplicateAttributeKeys(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestDuplicateKeys',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'title',
'type' => 'string',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionDuplicateIndexKeys(array $data): void
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestDuplicateIndexKeys',
'documentSecurity' => true,
'permissions' => [],
'attributes' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
[
'key' => 'title_idx',
'type' => 'unique',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(400, $collection['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateCollectionWithIndexLengths(array $data): array
{
$databaseId = $data['databaseId'];
$collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'collectionId' => ID::unique(),
'name' => 'TestIndexLengths',
'documentSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'attributes' => [
[
'key' => 'longText',
'type' => 'string',
'size' => 10000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'text_idx',
'type' => 'key',
'attributes' => ['longText'],
'lengths' => [255],
],
],
]);
$this->assertEquals(201, $collection['headers']['status-code']);
$this->assertCount(1, $collection['body']['indexes']);
$this->assertEquals([255], $collection['body']['indexes'][0]['lengths']);
return [
'databaseId' => $databaseId,
'collectionId' => $collection['body']['$id'],
];
}
/**
* @depends testCreateCollection
*/
@@ -86,914 +86,6 @@ trait DatabasesBase
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithStringColumn(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestStringCol',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertEquals('TestStringCol', $table['body']['name']);
$this->assertCount(1, $table['body']['columns']);
$this->assertEquals('title', $table['body']['columns'][0]['key']);
$this->assertEquals('string', $table['body']['columns'][0]['type']);
$this->assertEquals(255, $table['body']['columns'][0]['size']);
$this->assertEquals(true, $table['body']['columns'][0]['required']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithMultipleColumnTypes(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestMultiCol',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'name',
'type' => 'string',
'size' => 100,
'required' => true,
],
[
'key' => 'age',
'type' => 'integer',
'required' => false,
'default' => 0,
'min' => 0,
'max' => 150,
],
[
'key' => 'score',
'type' => 'float',
'required' => false,
'default' => 0.0,
'min' => 0.0,
'max' => 100.0,
],
[
'key' => 'isActive',
'type' => 'boolean',
'required' => false,
'default' => true,
],
[
'key' => 'createdAt',
'type' => 'datetime',
'required' => false,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertEquals('TestMultiCol', $table['body']['name']);
$this->assertCount(5, $table['body']['columns']);
// Verify each column type
$columnKeys = array_column($table['body']['columns'], 'key');
$this->assertContains('name', $columnKeys);
$this->assertContains('age', $columnKeys);
$this->assertContains('score', $columnKeys);
$this->assertContains('isActive', $columnKeys);
$this->assertContains('createdAt', $columnKeys);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithArrayColumn(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestArrayCol',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'tags',
'type' => 'string',
'size' => 50,
'required' => false,
'array' => true,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertEquals('TestArrayCol', $table['body']['name']);
$this->assertCount(1, $table['body']['columns']);
$this->assertEquals('tags', $table['body']['columns'][0]['key']);
$this->assertEquals(true, $table['body']['columns'][0]['array']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithDefaultValues(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestDefaults',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'status',
'type' => 'string',
'size' => 20,
'required' => false,
'default' => 'pending',
],
[
'key' => 'count',
'type' => 'integer',
'required' => false,
'default' => 0,
],
[
'key' => 'enabled',
'type' => 'boolean',
'required' => false,
'default' => false,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(3, $table['body']['columns']);
// Find and verify default values
foreach ($table['body']['columns'] as $col) {
if ($col['key'] === 'status') {
$this->assertEquals('pending', $col['default']);
}
if ($col['key'] === 'count') {
$this->assertEquals(0, $col['default']);
}
if ($col['key'] === 'enabled') {
$this->assertEquals(false, $col['default']);
}
}
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithKeyIndex(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestKeyIndex',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['columns']);
$this->assertCount(1, $table['body']['indexes']);
$this->assertEquals('title_idx', $table['body']['indexes'][0]['key']);
$this->assertEquals('key', $table['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithUniqueIndex(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestUniqueIndex',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'email',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'email_unique',
'type' => 'unique',
'attributes' => ['email'],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['indexes']);
$this->assertEquals('email_unique', $table['body']['indexes'][0]['key']);
$this->assertEquals('unique', $table['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithFulltextIndex(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestFulltextIndex',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'content',
'type' => 'string',
'size' => 10000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'content_fulltext',
'type' => 'fulltext',
'attributes' => ['content'],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['indexes']);
$this->assertEquals('content_fulltext', $table['body']['indexes'][0]['key']);
$this->assertEquals('fulltext', $table['body']['indexes'][0]['type']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithCompoundIndex(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestCompoundIndex',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'userId',
'type' => 'string',
'size' => 36,
'required' => true,
],
[
'key' => 'createdAt',
'type' => 'datetime',
'required' => true,
],
],
'indexes' => [
[
'key' => 'user_date_idx',
'type' => 'key',
'attributes' => ['userId', 'createdAt'],
'orders' => ['ASC', 'DESC'],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(2, $table['body']['columns']);
$this->assertCount(1, $table['body']['indexes']);
$this->assertEquals('user_date_idx', $table['body']['indexes'][0]['key']);
$this->assertCount(2, $table['body']['indexes'][0]['columns']);
$this->assertEquals(['ASC', 'DESC'], $table['body']['indexes'][0]['orders']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithMultipleIndexes(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestMultiIndex',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'slug',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'body',
'type' => 'string',
'size' => 50000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
[
'key' => 'slug_unique',
'type' => 'unique',
'attributes' => ['slug'],
],
[
'key' => 'body_fulltext',
'type' => 'fulltext',
'attributes' => ['body'],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(3, $table['body']['columns']);
$this->assertCount(3, $table['body']['indexes']);
$indexKeys = array_column($table['body']['indexes'], 'key');
$this->assertContains('title_idx', $indexKeys);
$this->assertContains('slug_unique', $indexKeys);
$this->assertContains('body_fulltext', $indexKeys);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithEmptyArrays(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestEmptyArrays',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [],
'indexes' => [],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertEquals('TestEmptyArrays', $table['body']['name']);
$this->assertCount(0, $table['body']['columns']);
$this->assertCount(0, $table['body']['indexes']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithIntegerMinMax(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestIntMinMax',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'temperature',
'type' => 'integer',
'required' => false,
'min' => -50,
'max' => 50,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['columns']);
$this->assertEquals('temperature', $table['body']['columns'][0]['key']);
$this->assertEquals(-50, $table['body']['columns'][0]['min']);
$this->assertEquals(50, $table['body']['columns'][0]['max']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithFloatMinMax(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestFloatMinMax',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'rating',
'type' => 'float',
'required' => false,
'min' => 0.0,
'max' => 5.0,
'default' => 0.0,
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['columns']);
$this->assertEquals('rating', $table['body']['columns'][0]['key']);
$this->assertEquals(0.0, $table['body']['columns'][0]['min']);
$this->assertEquals(5.0, $table['body']['columns'][0]['max']);
$this->assertEquals(0.0, $table['body']['columns'][0]['default']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableInvalidColumnType(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestInvalidType',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'invalid',
'type' => 'invalid_type',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableInvalidIndexType(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestInvalidIndex',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'invalid_idx',
'type' => 'invalid_type',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableMissingColumnKey(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestMissingKey',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'type' => 'string',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableMissingColumnType(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestMissingType',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'field',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableStringSizeMissing(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestMissingSize',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'required' => false,
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableIndexOnNonExistentColumn(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestBadIndexCol',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'nonexistent_idx',
'type' => 'key',
'attributes' => ['nonexistent_field'],
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableDuplicateColumnKeys(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestDuplicateKeys',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
[
'key' => 'title',
'type' => 'string',
'size' => 100,
'required' => false,
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableDuplicateIndexKeys(array $data): void
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestDuplicateIndexKeys',
'rowSecurity' => true,
'permissions' => [],
'columns' => [
[
'key' => 'title',
'type' => 'string',
'size' => 255,
'required' => true,
],
],
'indexes' => [
[
'key' => 'title_idx',
'type' => 'key',
'attributes' => ['title'],
],
[
'key' => 'title_idx',
'type' => 'unique',
'attributes' => ['title'],
],
],
]);
$this->assertEquals(400, $table['headers']['status-code']);
}
/**
* @depends testCreateDatabase
*/
public function testCreateTableWithIndexLengths(array $data): array
{
$databaseId = $data['databaseId'];
$table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'TestIndexLengths',
'rowSecurity' => true,
'permissions' => [
Permission::create(Role::user($this->getUser()['$id'])),
],
'columns' => [
[
'key' => 'longText',
'type' => 'string',
'size' => 10000,
'required' => false,
],
],
'indexes' => [
[
'key' => 'text_idx',
'type' => 'key',
'attributes' => ['longText'],
'lengths' => [255],
],
],
]);
$this->assertEquals(201, $table['headers']['status-code']);
$this->assertCount(1, $table['body']['indexes']);
$this->assertEquals([255], $table['body']['indexes'][0]['lengths']);
return [
'databaseId' => $databaseId,
'tableId' => $table['body']['$id'],
];
}
/**
* @depends testCreateTable
*/