diff --git a/.env b/.env index 0df9cb42f4..9abfa756e1 100644 --- a/.env +++ b/.env @@ -39,7 +39,7 @@ _APP_REDIS_HOST=redis _APP_REDIS_PORT=6379 _APP_REDIS_PASS= _APP_REDIS_USER= -COMPOSE_PROFILES=mongodb +COMPOSE_PROFILES=mariadb,mongodb,postgresql _APP_DB_ADAPTER=mongodb _APP_DB_HOST=mongodb _APP_DB_PORT=27017 @@ -47,6 +47,15 @@ _APP_DB_SCHEMA=appwrite _APP_DB_USER=user _APP_DB_PASS=password _APP_DB_ROOT_PASS=rootsecretpassword +_APP_DB_ADAPTER_DOCUMENTSDB=mongodb +_APP_DB_HOST_DOCUMENTSDB=mongodb +_APP_DB_PORT_DOCUMENTSDB=27017 +_APP_DB_ADAPTER_VECTORSDB=postgresql +_APP_DB_HOST_VECTORSDB=postgresql +_APP_DB_PORT_VECTORSDB=5432 +_APP_EMBEDDING_MODELS=embeddinggemma +_APP_EMBEDDING_ENDPOINT='http://ollama:11434/api/embed' +_APP_EMBEDDING_TIMEOUT=30000 _APP_STORAGE_DEVICE=Local _APP_STORAGE_S3_ACCESS_KEY= _APP_STORAGE_S3_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a6ae039f0..aa6dbe2bc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -394,7 +394,8 @@ jobs: Webhooks, VCS, Messaging, - Migrations + Migrations, + Project ] include: - service: Databases diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cd9b3827e7..5cbec8f867 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,7 +16,7 @@ jobs: - name: Build the Docker image run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: 'appwrite_image:latest' format: 'sarif' @@ -35,7 +35,7 @@ jobs: - name: Check out code uses: actions/checkout@v6 - name: Run Trivy vulnerability scanner on filesystem - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: scan-type: 'fs' format: 'sarif' diff --git a/AGENTS.md b/AGENTS.md index bb24d9f4fe..4d11ff0ee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,107 +1,120 @@ -# AGENTS.md +# Appwrite -Appwrite is an end-to-end backend server for web, mobile, native, and backend apps. This guide provides context and instructions for AI coding agents working on the Appwrite codebase. +Self-hosted Backend-as-a-Service platform. Hybrid monolithic-microservice architecture built with PHP 8.3+ on Swoole, delivered as Docker containers. -## Project Overview +## Commands -Appwrite is a self-hosted Backend-as-a-Service (BaaS) platform that provides developers with a set of APIs and tools to build secure, scalable applications. The project uses a hybrid monolithic-microservice architecture built with PHP, running on Swoole for high performance. +| Command | Purpose | +|---------|---------| +| `docker compose up -d --force-recreate --build` | Build and start all services | +| `docker compose exec appwrite test tests/e2e/Services/[Service]` | Run E2E tests for a service | +| `docker compose exec appwrite test tests/e2e/Services/[Service] --filter=[Method]` | Run a single test method | +| `docker compose exec appwrite test tests/unit/` | Run unit tests | +| `composer format` | Auto-format code (Pint, PSR-12) | +| `composer format ` | Format a specific file | +| `composer lint ` | Check formatting of a file | +| `composer analyze` | Static analysis (PHPStan level 3) | +| `composer check` | Same as `analyze` | -**Key Technologies:** -- **Backend:** PHP 8.3+, Swoole -- **Libraries:** Utopia PHP -- **Database:** MariaDB, Redis -- **Cache:** Redis -- **Queue:** Redis -- **Containers:** Docker +## Stack -## Development Commands +- PHP 8.3+, Swoole 6.x (async runtime, replaces PHP-FPM) +- Utopia PHP framework (HTTP routing, CLI, DI, queue) +- MongoDB (default), MariaDB, MySQL, PostgreSQL (adapters via utopia-php/database) +- Redis (cache, queue, pub/sub) +- Docker + Traefik (reverse proxy) +- PHPUnit 12, Pint (PSR-12), PHPStan level 3 -```bash -# Run Appwrite -docker compose up -d --force-recreate --build +## Project layout -# Run specific test -docker compose exec appwrite test /usr/src/code/tests/e2e/Services/[ServiceName] --filter=[FunctionName] +- **src/Appwrite/Platform/Modules/** -- feature modules (Account, Avatars, Compute, Console, Databases, Functions, Health, Project, Projects, Proxy, Sites, Storage, Teams, Tokens, VCS, Webhooks) +- **src/Appwrite/Platform/Workers/** -- background job workers +- **src/Appwrite/Platform/Tasks/** -- CLI tasks +- **app/init.php** -- bootstrap (registers services, resources, listeners) +- **app/init/** -- configs, constants, locales, models, registers, resources, span, database filters/formats +- **bin/** -- CLI entry points: `worker-*` (14 workers), `schedule-*`, `queue-*`, plus `doctor`, `install`, `migrate`, `realtime`, `upgrade`, `ssl`, `vars`, `maintenance`, `interval`, `specs`, `sdks`, etc. +- **tests/e2e/** -- end-to-end tests per service +- **tests/unit/** -- unit tests +- **public/** -- static assets and generated SDKs -# Format code -composer format +## Module structure + +Each module under `src/Appwrite/Platform/Modules/{Name}/` contains: + +``` +Module.php -- registers all services for the module +Services/Http.php -- registers HTTP endpoints +Services/Workers.php -- registers background workers +Services/Tasks.php -- registers CLI tasks +Http/{Service}/ -- endpoint actions (Create.php, Get.php, Update.php, Delete.php, XList.php) +Workers/ -- worker implementations +Tasks/ -- CLI task implementations ``` -## Code Style Guidelines +HTTP endpoint nesting reflects the URL path. Sub-resources get subdirectories. For example, within the Functions module: +`Http/Deployments/Template/Create.php` -> `POST /v1/functions/:functionId/deployments/template` -- Follow [PSR-12](https://www.php-fig.org/psr/psr-12/) coding standard -- Use PSR-4 autoloading -- Strict type declarations where applicable -- Comprehensive PHPDoc comments +File names in Http directories must only be `Get.php`, `Create.php`, `Update.php`, `Delete.php`, or `XList.php`. For non-CRUD operations, model the endpoint as a property update. For example, updating a team membership status lives at `Teams/Http/Memberships/Status/Update.php` (`PATCH /v1/teams/:teamId/memberships/:membershipId/status`). -### Naming Conventions +Register new modules in `src/Appwrite/Platform/Appwrite.php`. Detailed module guide: `src/Appwrite/Platform/AGENTS.md`. -#### `resourceType` Naming Rule +## Action pattern (HTTP endpoints) -When a collection has a combination of `resourceType`, `resourceId`, and/or `resourceInternalId`, the value of `resourceType` MUST always be **plural** - for example: `functions`, `sites`, `deployments`. - -Examples: ```php -'resourceType' => 'functions' -'resourceType' => 'sites' -'resourceType' => 'deployments' +class Create extends Action +{ + public static function getName(): string { return 'createTeam'; } + + public function __construct() + { + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/teams') + ->desc('Create team') + ->groups(['api', 'teams']) + ->label('event', 'teams.[teamId].create') + ->label('scope', 'teams.write') + ->param('teamId', '', new CustomId(), 'Team ID.') + ->param('name', null, new Text(128), 'Team name.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $teamId, + string $name, + Response $response, + Database $dbForProject, + Event $queueForEvents, + ): void { + // implementation + } +} ``` -## Performance Patterns +Common injections: `$response`, `$request`, `$dbForProject`, `$dbForPlatform`, `$user`, `$project`, `$queueForEvents`, `$queueForMails`, `$queueForDeletes`. -### Document Update Optimization +## Conventions -When updating documents, always pass only the changed attributes as a sparse `Document` rather than the full document. This is more efficient because `updateDocument()` internally performs `array_merge($old, $new)`. +- PSR-12 formatting enforced by Pint. PSR-4 autoloading. +- `resourceType` values are always **plural**: `'functions'`, `'sites'`, `'deployments'`. +- When updating documents, pass only changed attributes as a sparse Document: + ```php + // correct + $dbForProject->updateDocument('users', $user->getId(), new Document([ + 'name' => $name, + ])); + // incorrect -- passing full document is inefficient + $user->setAttribute('name', $name); + $dbForProject->updateDocument('users', $user->getId(), $user); + ``` + Exceptions: migrations, `array_merge()` with `getArrayCopy()`, updates where nearly all attributes change, complex nested relationship logic requiring full document state. +- Avoid introducing dependencies outside the `utopia-php` ecosystem. +- Never hardcode credentials -- use environment variables. +- Code changes may require container restart. No central log location -- check relevant containers. -**Correct Pattern:** -```php -// Good: Pass only changed attributes directly -$user = $dbForProject->updateDocument('users', $user->getId(), new Document([ - 'name' => $name, - 'email' => $email, -])); -``` +## Cross-repo context -**Incorrect Pattern:** -```php -$user->setAttribute('name', $name); -$user->setAttribute('email', $email); - -// Bad: Passing full document is inefficient -$user = $dbForProject->updateDocument('users', $user->getId(), $user); -``` - -**Exceptions:** -- Migration files (need full document updates by design) -- Cases already using `array_merge()` with `getArrayCopy()` -- Updates where almost all attributes of the document change at once (sparse update provides little benefit compared to passing the full document) -- Complex nested relationship logic where full document state is required - -## Security Considerations - -### Critical Security Practices - -- **Never hardcode credentials** - Use environment variables -- **Rate limiting** - Respect abuse prevention mechanisms - -## Dependencies - -Avoid introducing new dependencies other than utopia-php. - -## Adding new endpoints - -When adding new endpoints, make sure to use modules and follow its patterns. Find instruction in [Modules AGENTS.md](src/Appwrite/Platform/AGENTS.md) file. - -## Pull Request Guidelines -### Before Submitting - -- Run `composer format` -- Update documentation if adding features -- Add/update tests for your changes -- Check that Docker build succeeds -`docs/specs/authentication.drawio.svg` - -## Known Issues and Gotchas - -- **Hot Reload:** Code changes require container restart in some cases -- **Logging:** There is no central place for logs, so when debugging, ensure to check all possibly relevant containers +Appwrite is the base server for `appwrite/cloud`. Changes to the Action pattern, module structure, DI system, or response models affect cloud. The `feat-dedicated-db` feature spans cloud, edge, and console. diff --git a/README.md b/README.md index 457863d236..9815229e43 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Before running the installation command, make sure you have [Docker](https://www ```bash docker run -it --rm \ + --publish 20080:20080 \ --volume /var/run/docker.sock:/var/run/docker.sock \ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \ --entrypoint="install" \ @@ -84,6 +85,7 @@ docker run -it --rm \ ```cmd docker run -it --rm ^ + --publish 20080:20080 ^ --volume //var/run/docker.sock:/var/run/docker.sock ^ --volume "%cd%"/appwrite:/usr/src/code/appwrite:rw ^ --entrypoint="install" ^ @@ -94,6 +96,7 @@ docker run -it --rm ^ ```powershell docker run -it --rm ` + --publish 20080:20080 ` --volume /var/run/docker.sock:/var/run/docker.sock ` --volume ${pwd}/appwrite:/usr/src/code/appwrite:rw ` --entrypoint="install" ` diff --git a/app/config/collections.php b/app/config/collections.php index a74e079dce..3af20ff2ac 100644 --- a/app/config/collections.php +++ b/app/config/collections.php @@ -4,6 +4,7 @@ $common = include __DIR__ . '/collections/common.php'; $projects = include __DIR__ . '/collections/projects.php'; $databases = include __DIR__ . '/collections/databases.php'; +$vectorsdb = include __DIR__ . '/collections/vectorsdb.php'; $platform = include __DIR__ . '/collections/platform.php'; $logs = include __DIR__ . '/collections/logs.php'; @@ -26,6 +27,7 @@ unset($common['files']); $collections = [ 'buckets' => $buckets, 'databases' => $databases, + 'vectorsdb' => $vectorsdb, 'projects' => array_merge_recursive($projects, $common), 'console' => array_merge_recursive($platform, $common), 'logs' => $logs, diff --git a/app/config/collections/projects.php b/app/config/collections/projects.php index 55dceb9b40..9568c59369 100644 --- a/app/config/collections/projects.php +++ b/app/config/collections/projects.php @@ -61,6 +61,15 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('database'), + 'type' => Database::VAR_STRING, + 'size' => 2000, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [], + ] ], 'indexes' => [ [ diff --git a/app/config/collections/vectorsdb.php b/app/config/collections/vectorsdb.php new file mode 100644 index 0000000000..817863cffa --- /dev/null +++ b/app/config/collections/vectorsdb.php @@ -0,0 +1,165 @@ + [ + '$collection' => ID::custom('databases'), + '$id' => ID::custom('collections'), + 'name' => 'Collections', + 'attributes' => [ + [ + '$id' => ID::custom('databaseInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('databaseId'), + 'type' => Database::VAR_STRING, + 'signed' => true, + 'size' => Database::LENGTH_KEY, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('name'), + 'type' => Database::VAR_STRING, + 'size' => 256, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('dimension'), + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('enabled'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('documentSecurity'), + 'type' => Database::VAR_BOOLEAN, + 'signed' => true, + 'size' => 0, + 'format' => '', + 'filters' => [], + 'required' => true, + 'default' => null, + 'array' => false, + ], + [ + '$id' => ID::custom('attributes'), + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['subQueryAttributes'], + ], + [ + '$id' => ID::custom('indexes'), + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => ['subQueryIndexes'], + ], + [ + '$id' => ID::custom('search'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 16384, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + ], + 'defaultAttributes' => [ + [ + '$id' => ID::custom('embeddings'), + 'type' => Database::VAR_VECTOR, + 'required' => true, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('metadata'), + 'type' => Database::VAR_OBJECT, + 'default' => [], + 'required' => false, + 'size' => 0, + 'signed' => false, + 'array' => false, + 'filters' => [], + ], + ], + 'indexes' => [ + [ + '$id' => ID::custom('_fulltext_search'), + 'type' => Database::INDEX_FULLTEXT, + 'attributes' => ['search'], + 'lengths' => [], + 'orders' => [], + ], + [ + '$id' => ID::custom('_key_name'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'], + 'lengths' => [256], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_enabled'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['enabled'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + [ + '$id' => ID::custom('_key_documentSecurity'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['documentSecurity'], + 'lengths' => [], + 'orders' => [Database::ORDER_ASC], + ], + ], + 'defaultIndexes' => [ + // not creating default indexes on the embeddings as it depends on the type of query users using the most + [ + '$id' => ID::custom('_key_metadata'), + 'type' => Database::INDEX_OBJECT, + 'attributes' => ['metadata'], + 'lengths' => [], + 'orders' => [], + ], + ] + ] +]; diff --git a/app/config/errors.php b/app/config/errors.php index 278dbb3458..ec2593d207 100644 --- a/app/config/errors.php +++ b/app/config/errors.php @@ -1206,6 +1206,11 @@ return [ 'description' => 'Migration is already in progress. You can check the status of the migration in your Appwrite Console\'s "Settings" > "Migrations".', 'code' => 409, ], + Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED => [ + 'name' => Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, + 'description' => 'The specified database type is not supported for CSV import or export operations.', + 'code' => 400, + ], /** Realtime */ Exception::REALTIME_MESSAGE_FORMAT_INVALID => [ diff --git a/app/config/locale/translations/en.json b/app/config/locale/translations/en.json index 8e59c40123..3d667a36ad 100644 --- a/app/config/locale/translations/en.json +++ b/app/config/locale/translations/en.json @@ -57,21 +57,21 @@ "emails.recovery.thanks": "Thanks,", "emails.recovery.buttonText": "Reset password", "emails.recovery.signature": "{{project}} team", - "emails.csvExport.success.subject": "Your CSV export is ready", - "emails.csvExport.success.preview": "Your data export has been completed successfully.", - "emails.csvExport.success.hello": "Hello {{user}},", - "emails.csvExport.success.body": "Your CSV export is ready to download. Click the button below to download your data export.", - "emails.csvExport.success.footer": "This download link will expire in 1 hour.", - "emails.csvExport.success.thanks": "Thanks,", - "emails.csvExport.success.buttonText": "Download CSV", - "emails.csvExport.success.signature": "Appwrite team", - "emails.csvExport.failure.subject": "Your CSV export failed - file too large", - "emails.csvExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.", - "emails.csvExport.failure.hello": "Hello {{user}},", - "emails.csvExport.failure.body": "Your CSV export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.", - "emails.csvExport.failure.footer": "If you have any questions, please contact our support team.", - "emails.csvExport.failure.thanks": "Thanks,", - "emails.csvExport.failure.signature": "{{project}} team", + "emails.dataExport.success.subject": "Your {{type}} export is ready", + "emails.dataExport.success.preview": "Your data export has been completed successfully.", + "emails.dataExport.success.hello": "Hello {{user}},", + "emails.dataExport.success.body": "Your {{type}} export is ready to download. Click the button below to download your data export.", + "emails.dataExport.success.footer": "This download link will expire in 1 hour.", + "emails.dataExport.success.thanks": "Thanks,", + "emails.dataExport.success.buttonText": "Download {{type}}", + "emails.dataExport.success.signature": "Appwrite team", + "emails.dataExport.failure.subject": "Your {{type}} export failed - file too large", + "emails.dataExport.failure.preview": "Your data export failed because the file size exceeds your plan limit.", + "emails.dataExport.failure.hello": "Hello {{user}},", + "emails.dataExport.failure.body": "Your {{type}} export could not be completed because the export file size ({{size}}MB) exceeds your plan limit. Please consider upgrading your plan or exporting a smaller dataset.", + "emails.dataExport.failure.footer": "If you have any questions, please contact our support team.", + "emails.dataExport.failure.thanks": "Thanks,", + "emails.dataExport.failure.signature": "{{project}} team", "emails.invitation.subject": "Invitation to {{team}} Team at {{project}}", "emails.invitation.preview": "{{owner}} invited you to join {{team}} at {{project}}", "emails.invitation.hello": "Hello {{user}},", diff --git a/app/config/roles.php b/app/config/roles.php index 4473176c23..116e8ac932 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -62,6 +62,8 @@ $admins = [ 'devKeys.write', 'webhooks.read', 'webhooks.write', + 'project.read', + 'project.write', 'locale.read', 'avatars.read', 'health.read', diff --git a/app/config/scopes/organization.php b/app/config/scopes/organization.php index ca4160881d..8d85662652 100644 --- a/app/config/scopes/organization.php +++ b/app/config/scopes/organization.php @@ -31,12 +31,4 @@ return [ "description" => "Access to create, update, and delete project\'s development keys", ], - "webhooks.read" => [ - "description" => - "Access to read project\'s webhooks", - ], - "webhooks.write" => [ - "description" => - "Access to create, update, and delete project\'s webhooks", - ], ]; diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index 1f318b0376..f5d8461aff 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -180,4 +180,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s webhooks", ], + "project.read" => [ + "description" => + "Access to read project\'s information", + ], + "project.write" => [ + "description" => + "Access to update project\'s information", + ], ]; diff --git a/app/config/sdks.php b/app/config/sdks.php index 1a808aa10a..47dc8845b6 100644 --- a/app/config/sdks.php +++ b/app/config/sdks.php @@ -250,26 +250,16 @@ return [ ], ], ], - [ - 'key' => 'markdown', - 'name' => 'Markdown', - 'version' => '0.3.0', - 'url' => 'https://github.com/appwrite/sdk-for-md.git', - 'package' => 'https://www.npmjs.com/package/@appwrite.io/docs', - 'enabled' => true, - 'beta' => false, - 'dev' => false, - 'hidden' => false, - 'family' => APP_SDK_PLATFORM_CONSOLE, - 'prism' => 'markdown', - 'source' => \realpath(__DIR__ . '/../sdks/console-md'), - 'gitUrl' => 'git@github.com:appwrite/sdk-for-md.git', - 'gitRepoName' => 'sdk-for-md', - 'gitUserName' => 'appwrite', - 'gitBranch' => 'dev', - 'repoBranch' => 'main', - 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/md/CHANGELOG.md'), - ], + ], + ], + + APP_SDK_PLATFORM_STATIC => [ + 'key' => APP_SDK_PLATFORM_STATIC, + 'name' => 'Static', + 'description' => 'SDK artifacts for Appwrite integrations that do not require a generated platform API specification.', + 'enabled' => true, + 'beta' => false, + 'sdks' => [ [ 'key' => 'agent-skills', 'name' => 'AgentSkills', @@ -279,9 +269,10 @@ return [ 'beta' => false, 'dev' => false, 'hidden' => false, - 'family' => APP_SDK_PLATFORM_CONSOLE, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, 'prism' => 'agent-skills', - 'source' => \realpath(__DIR__ . '/../sdks/console-agent-skills'), + 'source' => \realpath(__DIR__ . '/../sdks/static-agent-skills'), 'gitUrl' => 'git@github.com:appwrite/agent-skills.git', 'gitRepoName' => 'agent-skills', 'gitUserName' => 'appwrite', @@ -298,9 +289,10 @@ return [ 'beta' => false, 'dev' => false, 'hidden' => false, - 'family' => APP_SDK_PLATFORM_CONSOLE, + 'spec' => 'static', + 'family' => APP_SDK_PLATFORM_STATIC, 'prism' => 'cursor-plugin', - 'source' => \realpath(__DIR__ . '/../sdks/console-cursor-plugin'), + 'source' => \realpath(__DIR__ . '/../sdks/static-cursor-plugin'), 'gitUrl' => 'git@github.com:appwrite/cursor-plugin.git', 'gitRepoName' => 'cursor-plugin', 'gitUserName' => 'appwrite', @@ -494,6 +486,25 @@ return [ 'gitBranch' => 'dev', 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/swift/CHANGELOG.md'), ], + [ + 'key' => 'rust', + 'name' => 'Rust', + 'version' => '0.1.0', + 'url' => 'https://github.com/appwrite/sdk-for-rust', + 'package' => 'https://crates.io/crates/appwrite', + 'enabled' => true, + 'beta' => true, + 'dev' => true, + 'hidden' => false, + 'family' => APP_SDK_PLATFORM_SERVER, + 'prism' => 'rust', + 'source' => \realpath(__DIR__ . '/../sdks/server-rust'), + 'gitUrl' => 'git@github.com:appwrite/sdk-for-rust.git', + 'gitRepoName' => 'sdk-for-rust', + 'gitUserName' => 'appwrite', + 'gitBranch' => 'dev', + 'changelog' => \realpath(__DIR__ . '/../../docs/sdks/rust/CHANGELOG.md'), + ], [ 'key' => 'graphql', 'name' => 'GraphQL', diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6d33b45f0b..3d7db8f457 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -209,6 +209,22 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr $createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) { + // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) + $oauthProvider = null; + try { + $jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $payload = $jwtDecoder->decode($secret); + + if (empty($payload['provider'])) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $oauthProvider = $payload['provider']; + $secret = $payload['secret']; + } catch (\Ahc\Jwt\JWTException) { + // Not a JWT — use secret as-is (non-OAuth flows) + } + /** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */ $userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -220,6 +236,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res ?: $userFromRequest->tokenVerify(null, $secret, $proofForCode); if (!$verifiedToken) { + // Could mean invalid/expired JWT, or expired secret + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + // OAuth2 tokens must have a provider from the JWT + if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) { throw new Exception(Exception::USER_INVALID_TOKEN); } @@ -245,7 +267,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, - TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2, + TOKEN_TYPE_OAUTH2 => $oauthProvider, default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1899,7 +1921,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setParam('tokenId', $token->getId()) ; - $query['secret'] = $secret; + // Wrap secret in a JWT that also carries the provider name + $jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $query['secret'] = $jwtEncoder->encode([ + 'secret' => $secret, + 'provider' => $provider, + ]); $query['userId'] = $user->getId(); // If the `token` param is not set, we persist the session in a cookie diff --git a/app/controllers/api/graphql.php b/app/controllers/api/graphql.php index 2d0a840bd6..937380b643 100644 --- a/app/controllers/api/graphql.php +++ b/app/controllers/api/graphql.php @@ -28,12 +28,18 @@ use Utopia\Validator\Text; Http::init() ->groups(['graphql']) ->inject('project') + ->inject('user') + ->inject('request') + ->inject('response') ->inject('authorization') - ->action(function (Document $project, Authorization $authorization) { + ->action(function (Document $project, User $user, Request $request, Response $response, Authorization $authorization) { + $response->setUser($user); + $request->setUser($user); + if ( array_key_exists('graphql', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['graphql'] - && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } diff --git a/app/controllers/api/migrations.php b/app/controllers/api/migrations.php index bfb73189b5..45a663fb56 100644 --- a/app/controllers/api/migrations.php +++ b/app/controllers/api/migrations.php @@ -29,6 +29,7 @@ use Utopia\Migration\Resource; use Utopia\Migration\Sources\Appwrite; use Utopia\Migration\Sources\CSV; use Utopia\Migration\Sources\Firebase; +use Utopia\Migration\Sources\JSON; use Utopia\Migration\Sources\NHost; use Utopia\Migration\Sources\Supabase; use Utopia\Migration\Transfer; @@ -43,6 +44,25 @@ use Utopia\Validator\WhiteList; include_once __DIR__ . '/../shared/api.php'; +function getDatabaseTransferResourceServices(string $databaseType) +{ + return match($databaseType) { + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB => Transfer::GROUP_DATABASES_TABLES_DB, + DATABASE_TYPE_VECTORSDB => Transfer::GROUP_DATABASES_VECTOR_DB, + DATABASE_TYPE_DOCUMENTSDB => Transfer::GROUP_DATABASES_DOCUMENTS_DB + }; +} + +function getDatabaseResourceType(string $databaseType): string +{ + return match($databaseType) { + DATABASE_TYPE_VECTORSDB => Resource::TYPE_DATABASE_VECTORSDB, + DATABASE_TYPE_DOCUMENTSDB => Resource::TYPE_DATABASE_DOCUMENTSDB, + default => Resource::TYPE_DATABASE, + }; +} + Http::post('/v1/migrations/appwrite') ->groups(['api', 'migrations']) ->desc('Create Appwrite migration') @@ -427,8 +447,17 @@ Http::post('/v1/migrations/csv/imports') throw new \Exception('Unable to copy file'); } + // getting databasetype + $resources = explode(':', $resourceId); + $databaseId = $resources[0]; + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } $fileSize = $deviceForMigrations->getFileSize($newPath); - $resources = Transfer::extractServices([Transfer::GROUP_DATABASES]); + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); + $resourceType = getDatabaseResourceType($databaseType); $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => $migrationId, @@ -438,7 +467,7 @@ Http::post('/v1/migrations/csv/imports') 'destination' => Appwrite::getName(), 'resources' => $resources, 'resourceId' => $resourceId, - 'resourceType' => Resource::TYPE_DATABASE, + 'resourceType' => $resourceType, 'statusCounters' => '{}', 'resourceData' => '{}', 'errors' => [], @@ -547,25 +576,41 @@ Http::post('/v1/migrations/csv/exports') throw new Exception(Exception::COLLECTION_NOT_FOUND); } + // getting databasetype + $resources = explode(':', $resourceId); + $databaseId = $resources[0]; + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + $databaseType = $database->getAttribute('type'); + if (!in_array($databaseType, CSV_ALLOWED_DATABASE_TYPES)) { + throw new Exception(Exception::MIGRATION_DATABASE_TYPE_UNSUPPORTED, 'Database type not supported for csv'); + } + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + $validator = new Documents( attributes: $collection->getAttribute('attributes', []), indexes: $collection->getAttribute('indexes', []), idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, ); if (!$validator->isValid($parsedQueries)) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); } + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); + $resourceType = getDatabaseResourceType($databaseType); + $migration = $dbForProject->createDocument('migrations', new Document([ '$id' => ID::unique(), 'status' => 'pending', 'stage' => 'init', 'source' => Appwrite::getName(), 'destination' => CSV::getName(), - 'resources' => Transfer::extractServices([Transfer::GROUP_DATABASES]), + 'resources' => $resources, 'resourceId' => $resourceId, - 'resourceType' => Resource::TYPE_DATABASE, + 'resourceType' => $resourceType, 'statusCounters' => '{}', 'resourceData' => '{}', 'errors' => [], @@ -596,6 +641,291 @@ Http::post('/v1/migrations/csv/exports') ->dynamic($migration, Response::MODEL_MIGRATION); }); +Http::post('/v1/migrations/json/imports') + ->groups(['api', 'migrations']) + ->desc('Import documents from a JSON') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONImport', + description: '/docs/references/migrations/migration-json-import.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('bucketId', '', new UID(), 'Storage bucket unique ID. You can create a new storage bucket using the Storage service [server integration](https://appwrite.io/docs/server/storage#createBucket).') + ->param('fileId', '', new UID(), 'File ID.') + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database.') + ->param('internalFile', false, new Boolean(), 'Is the file stored in an internal bucket?', true) + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('deviceForFiles') + ->inject('deviceForMigrations') + ->inject('queueForEvents') + ->inject('queueForMigrations') + ->action(function ( + string $bucketId, + string $fileId, + string $resourceId, + bool $internalFile, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Device $deviceForFiles, + Device $deviceForMigrations, + Event $queueForEvents, + Migration $queueForMigrations + ) { + $bucket = $authorization->skip(function () use ($internalFile, $dbForPlatform, $dbForProject, $bucketId) { + if ($internalFile) { + return $dbForPlatform->getDocument('buckets', 'default'); + } + return $dbForProject->getDocument('buckets', $bucketId); + }); + + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + $file = $authorization->skip(fn () => $internalFile ? $dbForPlatform->getDocument('bucket_' . $bucket->getSequence(), $fileId) : $dbForProject->getDocument('bucket_' . $bucket->getSequence(), $fileId)); + if ($file->isEmpty()) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); + } + + $path = $file->getAttribute('path', ''); + if (!$deviceForFiles->exists($path)) { + throw new Exception(Exception::STORAGE_FILE_NOT_FOUND, 'File not found in ' . $path); + } + + // No encryption or compression on files above 20MB. + $hasEncryption = !empty($file->getAttribute('openSSLCipher')); + $compression = $file->getAttribute('algorithm', Compression::NONE); + $hasCompression = $compression !== Compression::NONE; + + $migrationId = ID::unique(); + $newPath = $deviceForMigrations->getPath($migrationId . '_' . $fileId . '.json'); + + if ($hasEncryption || $hasCompression) { + $source = $deviceForFiles->read($path); + + if ($hasEncryption) { + $source = OpenSSL::decrypt( + $source, + $file->getAttribute('openSSLCipher'), + System::getEnv('_APP_OPENSSL_KEY_V' . $file->getAttribute('openSSLVersion')), + 0, + hex2bin($file->getAttribute('openSSLIV')), + hex2bin($file->getAttribute('openSSLTag')) + ); + } + + if ($hasCompression) { + switch ($compression) { + case Compression::ZSTD: + $source = (new Zstd())->decompress($source); + break; + case Compression::GZIP: + $source = (new GZIP())->decompress($source); + break; + } + } + + // Manual write after decryption and/or decompression + if (!$deviceForMigrations->write($newPath, $source, 'application/json')) { + throw new \Exception('Unable to copy file'); + } + } elseif (!$deviceForFiles->transfer($path, $newPath, $deviceForMigrations)) { + throw new \Exception('Unable to copy file'); + } + + $fileSize = $deviceForMigrations->getFileSize($newPath); + + [$databaseId] = \explode(':', $resourceId, 2); + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + $databaseType = $database->getAttribute('type'); + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); + $resourceType = getDatabaseResourceType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => $migrationId, + 'status' => 'pending', + 'stage' => 'init', + 'source' => JSON::getName(), + 'destination' => Appwrite::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'path' => $newPath, + 'size' => $fileSize, + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $queueForMigrations + ->setMigration($migration) + ->setProject($project) + ->setPlatform($platform) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + +Http::post('/v1/migrations/json/exports') + ->groups(['api', 'migrations']) + ->desc('Export documents to JSON') + ->label('scope', 'migrations.write') + ->label('event', 'migrations.[migrationId].create') + ->label('audits.event', 'migration.create') + ->label('sdk', new Method( + namespace: 'migrations', + group: null, + name: 'createJSONExport', + description: '/docs/references/migrations/migration-json-export.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_ACCEPTED, + model: Response::MODEL_MIGRATION, + ) + ] + )) + ->param('resourceId', null, new CompoundUID(), 'Composite ID in the format {databaseId:collectionId}, identifying a collection within a database to export.') + ->param('filename', '', new Text(255), 'The name of the file to be created for the export, excluding the .json extension.') + ->param('columns', [], new ArrayList(new Text(Database::LENGTH_KEY)), 'List of attributes to export. If empty, all attributes will be exported. You can use the `*` wildcard to export all attributes from the collection.', true) + ->param('queries', [], new ArrayList(new Text(0)), 'Array of query strings generated using the Query class provided by the SDK to filter documents to export. [Learn more about queries](https://appwrite.io/docs/databases#querying-documents). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('notify', true, new Boolean(), 'Set to true to receive an email when the export is complete. Default is true.', true) + ->inject('user') + ->inject('response') + ->inject('dbForProject') + ->inject('dbForPlatform') + ->inject('authorization') + ->inject('project') + ->inject('platform') + ->inject('queueForEvents') + ->inject('queueForMigrations') + ->action(function ( + string $resourceId, + string $filename, + array $columns, + array $queries, + bool $notify, + Document $user, + Response $response, + Database $dbForProject, + Database $dbForPlatform, + Authorization $authorization, + Document $project, + array $platform, + Event $queueForEvents, + Migration $queueForMigrations + ) { + try { + $parsedQueries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $bucket = $authorization->skip(fn () => $dbForPlatform->getDocument('buckets', 'default')); + if ($bucket->isEmpty()) { + throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); + } + + [$databaseId, $collectionId] = \explode(':', $resourceId, 2); + if (empty($databaseId)) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + if (empty($collectionId)) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + if ($collection->isEmpty()) { + throw new Exception(Exception::COLLECTION_NOT_FOUND); + } + + $databaseType = $database->getAttribute('type'); + + // Schemaless databases (DocumentsDB, VectorsDB) allow queries on dynamic fields + $isSchemaless = in_array($databaseType, [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]); + + $validator = new Documents( + attributes: $collection->getAttribute('attributes', []), + indexes: $collection->getAttribute('indexes', []), + idAttributeType: $dbForProject->getAdapter()->getIdAttributeType(), + supportForAttributes: !$isSchemaless, + ); + + if (!$validator->isValid($parsedQueries)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $resources = Transfer::extractServices([getDatabaseTransferResourceServices($databaseType)]); + $resourceType = getDatabaseResourceType($databaseType); + + $migration = $dbForProject->createDocument('migrations', new Document([ + '$id' => ID::unique(), + 'status' => 'pending', + 'stage' => 'init', + 'source' => Appwrite::getName(), + 'destination' => JSON::getName(), + 'resources' => $resources, + 'resourceId' => $resourceId, + 'resourceType' => $resourceType, + 'statusCounters' => '{}', + 'resourceData' => '{}', + 'errors' => [], + 'options' => [ + 'bucketId' => 'default', // Always use internal bucket + 'filename' => $filename, + 'columns' => $columns, + 'queries' => $queries, + 'notify' => $notify, + 'userInternalId' => $user->getSequence(), + ], + ])); + + $queueForEvents->setParam('migrationId', $migration->getId()); + + $queueForMigrations + ->setMigration($migration) + ->setProject($project) + ->setPlatform($platform) + ->trigger(); + + $response + ->setStatusCode(Response::STATUS_CODE_ACCEPTED) + ->dynamic($migration, Response::MODEL_MIGRATION); + }); + Http::get('/v1/migrations') ->groups(['api', 'migrations']) ->desc('List migrations') @@ -713,12 +1043,11 @@ Http::get('/v1/migrations/appwrite/report') ->param('projectID', '', new Text(512), "Source's Project ID") ->param('key', '', new Text(512), "Source's API Key") ->inject('response') - ->inject('dbForProject') - ->inject('project') - ->inject('user') - ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response) { + ->inject('getDatabasesDB') + ->action(function (array $resources, string $endpoint, string $projectID, string $key, Response $response, callable $getDatabasesDB) { + try { - $appwrite = new Appwrite($projectID, $endpoint, $key); + $appwrite = new Appwrite($projectID, $endpoint, $key, $getDatabasesDB); $report = $appwrite->report($resources); } catch (\Throwable $e) { throw new Exception( diff --git a/app/controllers/api/project.php b/app/controllers/api/project.php index d24519e3fb..054a7c8f0d 100644 --- a/app/controllers/api/project.php +++ b/app/controllers/api/project.php @@ -1,25 +1,15 @@ [ METRIC_NETWORK_REQUESTS, @@ -80,11 +87,26 @@ Http::get('/v1/project/usage') METRIC_USERS, METRIC_EXECUTIONS, METRIC_DATABASES_STORAGE, + METRIC_DATABASES_STORAGE_DOCUMENTSDB, METRIC_EXECUTIONS_MB_SECONDS, METRIC_BUILDS_MB_SECONDS, METRIC_DATABASES_OPERATIONS_READS, + METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB, METRIC_DATABASES_OPERATIONS_WRITES, + METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB, METRIC_FILES_IMAGES_TRANSFORMED, + // VectorsDB time series + METRIC_DATABASES_VECTORSDB, + METRIC_COLLECTIONS_VECTORSDB, + METRIC_DOCUMENTS_VECTORSDB, + METRIC_DATABASES_STORAGE_VECTORSDB, + METRIC_DATABASES_OPERATIONS_READS_VECTORSDB, + METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB, + // Embeddings time series + METRIC_EMBEDDINGS_TEXT, + METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, + METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, + METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR ] ]; @@ -357,8 +379,11 @@ Http::get('/v1/project/usage') 'buildsMbSecondsTotal' => $total[METRIC_BUILDS_MB_SECONDS], 'documentsTotal' => $total[METRIC_DOCUMENTS], 'rowsTotal' => $total[METRIC_DOCUMENTS], + 'documentsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_DOCUMENTSDB], 'databasesTotal' => $total[METRIC_DATABASES], + 'documentsdbTotal' => $total[METRIC_DATABASES_DOCUMENTSDB], 'databasesStorageTotal' => $total[METRIC_DATABASES_STORAGE], + 'documentsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_DOCUMENTSDB], 'usersTotal' => $total[METRIC_USERS], 'bucketsTotal' => $total[METRIC_BUCKETS], 'filesStorageTotal' => $total[METRIC_FILES_STORAGE], @@ -367,10 +392,27 @@ Http::get('/v1/project/usage') 'deploymentsStorageTotal' => $total[METRIC_DEPLOYMENTS_STORAGE], 'databasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS], 'databasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES], + 'documentsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB], + 'documentsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB], + 'vectorsdbDatabasesTotal' => $total[METRIC_DATABASES_VECTORSDB] ?? 0, + 'vectorsdbCollectionsTotal' => $total[METRIC_COLLECTIONS_VECTORSDB] ?? 0, + 'vectorsdbDocumentsTotal' => $total[METRIC_DOCUMENTS_VECTORSDB] ?? 0, + 'vectorsdbDatabasesStorageTotal' => $total[METRIC_DATABASES_STORAGE_VECTORSDB] ?? 0, + 'vectorsdbDatabasesReadsTotal' => $total[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? 0, + 'vectorsdbDatabasesWritesTotal' => $total[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? 0, 'executionsBreakdown' => $executionsBreakdown, 'bucketsBreakdown' => $bucketsBreakdown, 'databasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS], 'databasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES], + 'documentsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB], + 'documentsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB], + 'documentsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_DOCUMENTSDB], + 'vectorsdbDatabases' => $usage[METRIC_DATABASES_VECTORSDB] ?? [], + 'vectorsdbCollections' => $usage[METRIC_COLLECTIONS_VECTORSDB] ?? [], + 'vectorsdbDocuments' => $usage[METRIC_DOCUMENTS_VECTORSDB] ?? [], + 'vectorsdbDatabasesStorage' => $usage[METRIC_DATABASES_STORAGE_VECTORSDB] ?? [], + 'vectorsdbDatabasesReads' => $usage[METRIC_DATABASES_OPERATIONS_READS_VECTORSDB] ?? [], + 'vectorsdbDatabasesWrites' => $usage[METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB] ?? [], 'databasesStorageBreakdown' => $databasesStorageBreakdown, 'executionsMbSecondsBreakdown' => $executionsMbSecondsBreakdown, 'buildsMbSecondsBreakdown' => $buildsMbSecondsBreakdown, @@ -380,230 +422,13 @@ Http::get('/v1/project/usage') 'authPhoneCountryBreakdown' => $authPhoneCountryBreakdown, 'imageTransformations' => $usage[METRIC_FILES_IMAGES_TRANSFORMED], 'imageTransformationsTotal' => $total[METRIC_FILES_IMAGES_TRANSFORMED], + 'embeddingsText' => $usage[METRIC_EMBEDDINGS_TEXT] ?? [], + 'embeddingsTextTokens' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? [], + 'embeddingsTextDuration' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? [], + 'embeddingsTextErrors' => $usage[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? [], + 'embeddingsTextTotal' => $total[METRIC_EMBEDDINGS_TEXT] ?? 0, + 'embeddingsTextTokensTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS] ?? 0, + 'embeddingsTextDurationTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION] ?? 0, + 'embeddingsTextErrorsTotal' => $total[METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR] ?? 0, ]), Response::MODEL_USAGE_PROJECT); }); - - -// Variables -Http::post('/v1/project/variables') - ->desc('Create variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('audits.event', 'variable.create') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'createVariable', - description: '/docs/references/project/create-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_CREATED, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.', false) - ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.', false) - ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $key, string $value, bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) { - $variableId = ID::unique(); - - $variable = new Document([ - '$id' => $variableId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::update(Role::any()), - Permission::delete(Role::any()), - ], - 'resourceInternalId' => '', - 'resourceId' => '', - 'resourceType' => 'project', - 'key' => $key, - 'value' => $value, - 'secret' => $secret, - 'search' => implode(' ', [$variableId, $key, 'project']), - ]); - - try { - $variable = $dbForProject->createDocument('variables', $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response - ->setStatusCode(Response::STATUS_CODE_CREATED) - ->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::get('/v1/project/variables') - ->desc('List variables') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'listVariables', - description: '/docs/references/project/list-variables.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE_LIST, - ) - ] - )) - ->inject('response') - ->inject('dbForProject') - ->action(function (Response $response, Database $dbForProject) { - $variables = $dbForProject->find('variables', [ - Query::equal('resourceType', ['project']), - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - $response->dynamic(new Document([ - 'variables' => $variables, - 'total' => \count($variables), - ]), Response::MODEL_VARIABLE_LIST); - }); - -Http::get('/v1/project/variables/:variableId') - ->desc('Get variable') - ->groups(['api']) - ->label('scope', 'projects.read') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'getVariable', - description: '/docs/references/project/get-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->inject('response') - ->inject('project') - ->inject('dbForProject') - ->action(function (string $variableId, Response $response, Document $project, Database $dbForProject) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::put('/v1/project/variables/:variableId') - ->desc('Update variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'updateVariable', - description: '/docs/references/project/update-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_VARIABLE, - ) - ] - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->param('key', null, new Text(255), 'Variable key. Max length: 255 chars.', false) - ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) - ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->inject('dbForPlatform') - ->action(function (string $variableId, string $key, ?string $value, ?bool $secret, Document $project, Response $response, Database $dbForProject, Database $dbForPlatform) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - if ($variable->getAttribute('secret') === true && $secret === false) { - throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); - } - - $variable - ->setAttribute('key', $key) - ->setAttribute('value', $value ?? $variable->getAttribute('value')) - ->setAttribute('secret', $secret ?? $variable->getAttribute('secret')) - ->setAttribute('search', implode(' ', [$variableId, $key, 'project'])); - - try { - $dbForProject->updateDocument('variables', $variable->getId(), $variable); - } catch (DuplicateException $th) { - throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); - } - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response->dynamic($variable, Response::MODEL_VARIABLE); - }); - -Http::delete('/v1/project/variables/:variableId') - ->desc('Delete variable') - ->groups(['api']) - ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'project', - group: null, - name: 'deleteVariable', - description: '/docs/references/project/delete-variable.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_NOCONTENT, - model: Response::MODEL_NONE, - ) - ], - contentType: ContentType::NONE - )) - ->param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable unique ID.', false, ['dbForProject']) - ->inject('project') - ->inject('response') - ->inject('dbForProject') - ->action(function (string $variableId, Document $project, Response $response, Database $dbForProject) { - $variable = $dbForProject->getDocument('variables', $variableId); - if ($variable === false || $variable->isEmpty() || $variable->getAttribute('resourceType') !== 'project') { - throw new Exception(Exception::VARIABLE_NOT_FOUND); - } - - $dbForProject->deleteDocument('variables', $variable->getId()); - - $functions = $dbForProject->find('functions', [ - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - foreach ($functions as $function) { - $dbForProject->updateDocument('functions', $function->getId(), $function->setAttribute('live', false)); - } - - $response->noContent(); - }); diff --git a/app/controllers/general.php b/app/controllers/general.php index 1a099c4bde..79929816d9 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -1190,6 +1190,15 @@ Http::error() ->inject('devKey') ->inject('authorization') ->action(function (Throwable $error, Http $utopia, Request $request, Response $response, Document $project, ?Logger $logger, Log $log, Bus $bus, Document $devKey, Authorization $authorization) { + $trace = $error->getTrace(); + + foreach (array_slice($trace, 0, 100) as $index => $traceEntry) { + $file = isset($traceEntry['file']) ? $traceEntry['file'] : '[internal function]'; + $line = isset($traceEntry['line']) ? $traceEntry['line'] : ''; + $function = isset($traceEntry['function']) ? $traceEntry['function'] : ''; + Console::error("[$index] $file : $line -> $function()"); + } + $version = System::getEnv('_APP_VERSION', 'UNKNOWN'); $route = $utopia->getRoute(); $class = \get_class($error); @@ -1261,7 +1270,16 @@ Http::error() * If not a publishable error, track usage stats. Publishable errors are >= 500 or those explicitly marked as publish=true in errors.php */ if (!$publish && $project->getId() !== 'console') { - if (!DBUser::isPrivileged($authorization->getRoles())) { + $errorUser = new DBUser(); + try { + $resolvedUser = $utopia->getResource('user'); + if ($resolvedUser instanceof DBUser) { + $errorUser = $resolvedUser; + } + } catch (\Throwable) { + // User resource may not be available in error context + } + if (!$errorUser->isPrivileged($authorization->getRoles())) { $bus->dispatch(new RequestCompleted( project: $project->getArrayCopy(), request: $request, diff --git a/app/controllers/shared/api.php b/app/controllers/shared/api.php index 622f7a8678..acf5cdcd9a 100644 --- a/app/controllers/shared/api.php +++ b/app/controllers/shared/api.php @@ -96,7 +96,7 @@ Http::init() ->inject('team') ->inject('apiKey') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, User $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey, Authorization $authorization) { $route = $utopia->getRoute(); /** @@ -426,7 +426,7 @@ Http::init() if ( array_key_exists($namespace, $project->getAttribute('services', [])) && ! $project->getAttribute('services', [])[$namespace] - && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new Exception(Exception::GENERAL_SERVICE_DISABLED); } @@ -490,14 +490,23 @@ Http::init() ->inject('telemetry') ->inject('platform') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Response $response, Document $project, Document $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Response $response, Document $project, User $user, Event $queueForEvents, Messaging $queueForMessaging, Audit $queueForAudits, Delete $queueForDeletes, EventDatabase $queueForDatabase, Build $queueForBuilds, Context $usage, Func $queueForFunctions, Mail $queueForMails, Database $dbForProject, callable $timelimit, Document $resourceToken, string $mode, ?Key $apiKey, array $plan, Document $devKey, Telemetry $telemetry, array $platform, Authorization $authorization) { + + $response->setUser($user); + $request->setUser($user); $route = $utopia->getRoute(); + $path = $route->getMatchedPath(); + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; if ( array_key_exists('rest', $project->getAttribute('apis', [])) && ! $project->getAttribute('apis', [])['rest'] - && ! (User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && ! ($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -529,8 +538,8 @@ Http::init() $closestLimit = null; $roles = $authorization->getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); foreach ($timeLimitArray as $timeLimit) { foreach ($request->getParams() as $key => $value) { // Set request params as potential abuse keys @@ -614,7 +623,7 @@ Http::init() if ($useCache) { $route = $utopia->match($request); $isImageTransformation = $route->getPath() === '/v1/storage/buckets/:bucketId/files/:fileId/preview'; - $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! User::isPrivileged($authorization->getRoles()); + $isDisabled = isset($plan['imageTransformations']) && $plan['imageTransformations'] === -1 && ! $user->isPrivileged($authorization->getRoles()); $key = $request->cacheIdentifier(); $cacheLog = $authorization->skip(fn () => $dbForProject->getDocument('cache', $key)); @@ -633,7 +642,7 @@ Http::init() $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); $isToken = ! $resourceToken->isEmpty() && $resourceToken->getAttribute('bucketInternalId') === $bucket->getSequence(); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (! $bucket->getAttribute('enabled') && ! $isAppUser && ! $isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -666,7 +675,7 @@ Http::init() throw new Exception(Exception::STORAGE_FILE_NOT_FOUND); } // Do not update transformedAt if it's a console user - if (! User::isPrivileged($authorization->getRoles())) { + if (! $user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); @@ -700,7 +709,7 @@ Http::init() ->groups(['session']) ->inject('user') ->inject('request') - ->action(function (Document $user, Request $request) { + ->action(function (User $user, Request $request) { if (\str_contains($request->getURI(), 'oauth2')) { return; } @@ -990,7 +999,7 @@ Http::shutdown() } if ($project->getId() !== 'console') { - if (! User::isPrivileged($authorization->getRoles())) { + if (! $user->isPrivileged($authorization->getRoles())) { $bus->dispatch(new RequestCompleted( project: $project->getArrayCopy(), request: $request, diff --git a/app/controllers/shared/api/auth.php b/app/controllers/shared/api/auth.php index 6e1f9f389f..db98d97bf5 100644 --- a/app/controllers/shared/api/auth.php +++ b/app/controllers/shared/api/auth.php @@ -36,8 +36,9 @@ Http::init() ->inject('request') ->inject('project') ->inject('geodb') + ->inject('user') ->inject('authorization') - ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, Authorization $authorization) { + ->action(function (Http $utopia, Request $request, Document $project, Reader $geodb, User $user, Authorization $authorization) { $denylist = System::getEnv('_APP_CONSOLE_COUNTRIES_DENYLIST', ''); if (!empty($denylist && $project->getId() === 'console')) { $countries = explode(',', $denylist); @@ -50,8 +51,8 @@ Http::init() $route = $utopia->match($request); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $isAppUser = User::isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); if ($isAppUser || $isPrivilegedUser) { // Skip limits for app and console devs return; diff --git a/app/http.php b/app/http.php index 1302940856..1742bc7cdd 100644 --- a/app/http.php +++ b/app/http.php @@ -103,7 +103,7 @@ function dispatch(Server $server, int $fd, int $type, $data = null): int $lines = explode("\n", $data, 3); $request = $lines[0]; if (count($lines) > 1) { - $domain = trim(explode('Host: ', $lines[1])[1]); + $domain = trim(explode('Host: ', $lines[1])[1] ?? ''); } // Sync executions are considered risky @@ -196,6 +196,8 @@ include __DIR__ . '/controllers/general.php'; function createDatabase(Http $app, string $resourceKey, string $dbName, array $collections, mixed $pools, ?callable $extraSetup = null): void { + $max = 15; + $sleep = 2; $max = 15; $sleep = 2; $attempts = 0; @@ -409,13 +411,29 @@ $http->on(Constant::EVENT_START, function (Server $http) use ($payloadSize, $tot }); $projectCollections = $collections['projects']; + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); $sharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); $sharedTablesV2 = \array_diff($sharedTables, $sharedTablesV1); + $documentsSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); + $documentsSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + $documentsSharedTablesV2 = \array_diff($documentsSharedTables, $documentsSharedTablesV1); + + $vectorSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); + $vectorSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + $vectorSharedTablesV2 = \array_diff($vectorSharedTables, $vectorSharedTablesV1); + $cache = $app->getResource('cache'); - foreach ($sharedTablesV2 as $hostname) { + // All shared tables V2 pools that need project metadata collections + $sharedTablesV2All = \array_values(\array_unique(\array_filter([ + ...$sharedTablesV2, + ...$documentsSharedTablesV2, + ...$vectorSharedTablesV2, + ]))); + + foreach ($sharedTablesV2All as $hostname) { Span::init('database.setup'); Span::add('database.hostname', $hostname); diff --git a/app/init/constants.php b/app/init/constants.php index 9d524a7acd..3b907572ab 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -97,6 +97,7 @@ const APP_COMPUTE_DEPLOYMENT_MAX_RETENTION = 100 * 365; // 100 years const APP_SDK_PLATFORM_SERVER = 'server'; const APP_SDK_PLATFORM_CLIENT = 'client'; const APP_SDK_PLATFORM_CONSOLE = 'console'; +const APP_SDK_PLATFORM_STATIC = 'static'; const APP_VCS_GITHUB_USERNAME = 'Appwrite'; const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite'; @@ -291,6 +292,45 @@ const METRIC_DATABASES_OPERATIONS_READS = 'databases.operations.reads'; const METRIC_DATABASE_ID_OPERATIONS_READS = '{databaseInternalId}.databases.operations.reads'; const METRIC_DATABASES_OPERATIONS_WRITES = 'databases.operations.writes'; const METRIC_DATABASE_ID_OPERATIONS_WRITES = '{databaseInternalId}.databases.operations.writes'; + +// documentsdb +const METRIC_DATABASES_DOCUMENTSDB = 'documentsdb.databases'; +const METRIC_COLLECTIONS_DOCUMENTSDB = 'documentsdb.collections'; +const METRIC_DATABASES_STORAGE_DOCUMENTSDB = 'documentsdb.databases.storage'; +const METRIC_DATABASE_ID_COLLECTIONS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.collections'; +const METRIC_DATABASE_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.storage'; +const METRIC_DOCUMENTS_DOCUMENTSDB = 'documentsdb.documents'; +const METRIC_DATABASE_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.{collectionInternalId}.databases.storage'; +const METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.databases.operations.reads'; +const METRIC_DATABASE_ID_OPERATIONS_READS_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.reads'; +const METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.databases.operations.writes'; +const METRIC_DATABASE_ID_OPERATIONS_WRITES_DOCUMENTSDB = 'documentsdb.{databaseInternalId}.databases.operations.writes'; + +// vectorsdb +const METRIC_DATABASES_VECTORSDB = 'vectorsdb.databases'; +const METRIC_COLLECTIONS_VECTORSDB = 'vectorsdb.collections'; +const METRIC_DATABASES_STORAGE_VECTORSDB = 'vectorsdb.databases.storage'; +const METRIC_DATABASE_ID_COLLECTIONS_VECTORSDB = 'vectorsdb.{databaseInternalId}.collections'; +const METRIC_DATABASE_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.storage'; +const METRIC_DOCUMENTS_VECTORSDB = 'vectorsdb.documents'; +const METRIC_DATABASE_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.documents'; +const METRIC_DATABASE_ID_COLLECTION_ID_STORAGE_VECTORSDB = 'vectorsdb.{databaseInternalId}.{collectionInternalId}.databases.storage'; +const METRIC_DATABASES_OPERATIONS_READS_VECTORSDB = 'vectorsdb.databases.operations.reads'; +const METRIC_DATABASE_ID_OPERATIONS_READS_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.reads'; +const METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.databases.operations.writes'; +const METRIC_DATABASE_ID_OPERATIONS_WRITES_VECTORSDB = 'vectorsdb.{databaseInternalId}.databases.operations.writes'; +const METRIC_EMBEDDINGS_TEXT = 'embeddings.text'; +const METRIC_EMBEDDINGS_MODEL_TEXT = 'embeddings.text.{embeddingModel}'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR = 'embeddings.text.totalErrors'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR = 'embeddings.text.{embeddingModel}.totalErrors'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION = 'embeddings.text.totalDuration'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION = 'embeddings.text.{embeddingModel}.totalDuration'; +const METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS = 'embeddings.text.totalTokens'; +const METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS = 'embeddings.text.{embeddingModel}.totalTokens'; + const METRIC_BUCKETS = 'buckets'; const METRIC_FILES = 'files'; const METRIC_FILES_STORAGE = 'files.storage'; @@ -383,6 +423,7 @@ const RESOURCE_TYPE_SUBSCRIBERS = 'subscribers'; const RESOURCE_TYPE_MESSAGES = 'messages'; const RESOURCE_TYPE_EXECUTIONS = 'executions'; const RESOURCE_TYPE_VCS = 'vcs'; +const RESOURCE_TYPE_EMBEDDINGS_TEXT = 'embeddingsText'; // Resource types for Tokens const TOKENS_RESOURCE_TYPE_FILES = 'files'; @@ -404,3 +445,16 @@ const CACHE_RECONNECT_RETRY_DELAY = 1000; // Project status const PROJECT_STATUS_ACTIVE = 'active'; + +// Database types +const DATABASE_TYPE_LEGACY = 'legacy'; +const DATABASE_TYPE_TABLESDB = 'tablesdb'; +const DATABASE_TYPE_DOCUMENTSDB = 'documentsdb'; +const DATABASE_TYPE_VECTORSDB = 'vectorsdb'; + +// CSV import/export allowed database types +const CSV_ALLOWED_DATABASE_TYPES = [ + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB, + DATABASE_TYPE_VECTORSDB +]; diff --git a/app/init/models.php b/app/init/models.php index 6c90f08199..bf6d67dd95 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -22,6 +22,7 @@ use Appwrite\Utopia\Response\Model\AttributeLine; use Appwrite\Utopia\Response\Model\AttributeList; use Appwrite\Utopia\Response\Model\AttributeLongtext; use Appwrite\Utopia\Response\Model\AttributeMediumtext; +use Appwrite\Utopia\Response\Model\AttributeObject; use Appwrite\Utopia\Response\Model\AttributePoint; use Appwrite\Utopia\Response\Model\AttributePolygon; use Appwrite\Utopia\Response\Model\AttributeRelationship; @@ -29,6 +30,7 @@ use Appwrite\Utopia\Response\Model\AttributeString; use Appwrite\Utopia\Response\Model\AttributeText; use Appwrite\Utopia\Response\Model\AttributeURL; use Appwrite\Utopia\Response\Model\AttributeVarchar; +use Appwrite\Utopia\Response\Model\AttributeVector; use Appwrite\Utopia\Response\Model\AuthProvider; use Appwrite\Utopia\Response\Model\BaseList; use Appwrite\Utopia\Response\Model\Branch; @@ -65,6 +67,7 @@ use Appwrite\Utopia\Response\Model\DetectionRuntime; use Appwrite\Utopia\Response\Model\DetectionVariable; use Appwrite\Utopia\Response\Model\DevKey; use Appwrite\Utopia\Response\Model\Document as ModelDocument; +use Appwrite\Utopia\Response\Model\Embedding; use Appwrite\Utopia\Response\Model\Error; use Appwrite\Utopia\Response\Model\ErrorDev; use Appwrite\Utopia\Response\Model\Execution; @@ -136,6 +139,8 @@ use Appwrite\Utopia\Response\Model\UsageBuckets; use Appwrite\Utopia\Response\Model\UsageCollection; use Appwrite\Utopia\Response\Model\UsageDatabase; use Appwrite\Utopia\Response\Model\UsageDatabases; +use Appwrite\Utopia\Response\Model\UsageDocumentsDB; +use Appwrite\Utopia\Response\Model\UsageDocumentsDBs; use Appwrite\Utopia\Response\Model\UsageFunction; use Appwrite\Utopia\Response\Model\UsageFunctions; use Appwrite\Utopia\Response\Model\UsageProject; @@ -144,9 +149,12 @@ use Appwrite\Utopia\Response\Model\UsageSites; use Appwrite\Utopia\Response\Model\UsageStorage; use Appwrite\Utopia\Response\Model\UsageTable; use Appwrite\Utopia\Response\Model\UsageUsers; +use Appwrite\Utopia\Response\Model\UsageVectorsDB; +use Appwrite\Utopia\Response\Model\UsageVectorsDBs; use Appwrite\Utopia\Response\Model\User; use Appwrite\Utopia\Response\Model\Variable; use Appwrite\Utopia\Response\Model\VcsContent; +use Appwrite\Utopia\Response\Model\VectorsDBCollection; use Appwrite\Utopia\Response\Model\Webhook; // General @@ -211,9 +219,12 @@ Response::setModel(new BaseList('Migrations List', Response::MODEL_MIGRATION_LIS Response::setModel(new BaseList('Migrations Firebase Projects List', Response::MODEL_MIGRATION_FIREBASE_PROJECT_LIST, 'projects', Response::MODEL_MIGRATION_FIREBASE_PROJECT)); Response::setModel(new BaseList('Specifications List', Response::MODEL_SPECIFICATION_LIST, 'specifications', Response::MODEL_SPECIFICATION)); Response::setModel(new BaseList('VCS Content List', Response::MODEL_VCS_CONTENT_LIST, 'contents', Response::MODEL_VCS_CONTENT)); +Response::setModel(new BaseList('VectorsDB Collections List', Response::MODEL_VECTORSDB_COLLECTION_LIST, 'collections', Response::MODEL_VECTORSDB_COLLECTION)); +Response::setModel(new BaseList('Embedding list', Response::MODEL_EMBEDDING_LIST, 'embeddings', Response::MODEL_EMBEDDING)); // Entities Response::setModel(new Database()); +Response::setModel(new Embedding()); // Collection API Models Response::setModel(new Collection()); @@ -237,6 +248,17 @@ Response::setModel(new AttributeText()); Response::setModel(new AttributeMediumtext()); Response::setModel(new AttributeLongtext()); +// DocumentsDB API Models +Response::setModel(new UsageDocumentsDBs()); +Response::setModel(new UsageDocumentsDB()); + +// VectorsDB API Models +Response::setModel(new VectorsDBCollection()); +Response::setModel(new AttributeObject()); +Response::setModel(new AttributeVector()); +Response::setModel(new UsageVectorsDBs()); +Response::setModel(new UsageVectorsDB()); + // Table API Models Response::setModel(new Table()); Response::setModel(new Column()); diff --git a/app/init/registers.php b/app/init/registers.php index 7b68c2af9a..7c2f822fdd 100644 --- a/app/init/registers.php +++ b/app/init/registers.php @@ -160,7 +160,6 @@ $register->set('pools', function () { 'pass' => System::getEnv('_APP_DB_PASS', ''), 'path' => System::getEnv('_APP_DB_SCHEMA', ''), ]); - $fallbackForRedis = 'redis_main=' . AppwriteURL::unparse([ 'scheme' => 'redis', 'host' => System::getEnv('_APP_REDIS_HOST', 'redis'), @@ -169,6 +168,23 @@ $register->set('pools', function () { 'pass' => System::getEnv('_APP_REDIS_PASS', ''), ]); + $fallbackForDocumentsDB = 'db_main=' . AppwriteURL::unparse([ + 'scheme' => System::getEnv('_APP_DB_ADAPTER_DOCUMENTSDB', 'mongodb'), + 'host' => System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'), + 'port' => System::getEnv('_APP_DB_PORT_DOCUMENTSDB', '27017'), + 'user' => System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + ]); + $fallbackForVectorsDB = 'db_main=' . AppwriteURL::unparse([ + 'scheme' => System::getEnv('_APP_DB_ADAPTER_VECTORSDB', 'postgresql'), + 'host' => System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'), + 'port' => System::getEnv('_APP_DB_PORT_VECTORSDB', '5432'), + 'user' => System::getEnv('_APP_DB_USER', ''), + 'pass' => System::getEnv('_APP_DB_PASS', ''), + 'path' => System::getEnv('_APP_DB_SCHEMA', ''), + ]); + $connections = [ 'console' => [ 'type' => 'database', @@ -180,13 +196,25 @@ $register->set('pools', function () { 'type' => 'database', 'dsns' => $fallbackForDB, 'multiple' => true, - 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'], + 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'], + ], + 'documentsdb' => [ + 'type' => 'database', + 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_DOCUMENTSDB', $fallbackForDocumentsDB), + 'multiple' => true, + 'schemes' => ['mongodb'], + ], + 'vectorsdb' => [ + 'type' => 'database', + 'dsns' => System::getEnv('_APP_CONNECTIONS_DATABASE_VECTORSDB', $fallbackForVectorsDB), + 'multiple' => true, + 'schemes' => ['postgresql'], ], 'logs' => [ 'type' => 'database', 'dsns' => System::getEnv('_APP_CONNECTIONS_DB_LOGS', $fallbackForDB), 'multiple' => false, - 'schemes' => ['mariadb', 'mongodb', 'mysql', 'postgresql'], + 'schemes' => ['mongodb','mariadb', 'mysql','postgresql'], ], 'publisher' => [ 'type' => 'publisher', diff --git a/app/init/resources.php b/app/init/resources.php index 3465f22560..3481e73e0b 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -32,6 +32,8 @@ use Appwrite\Utopia\Request; use Appwrite\Utopia\Response; use Executor\Executor; use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis; +use Utopia\Agents\Adapters\Ollama; +use Utopia\Agents\Agent; use Utopia\Audit\Adapter\Database as AdapterDatabase; use Utopia\Audit\Audit; use Utopia\Auth\Hashes\Argon2; @@ -430,8 +432,10 @@ Http::setResource('user', function (string $mode, Document $project, Document $c $jwtUserId = $payload['userId'] ?? ''; if (! empty($jwtUserId)) { if ($mode === APP_MODE_ADMIN) { + /** @var User $user */ $user = $dbForPlatform->getDocument('users', $jwtUserId); } else { + /** @var User $user */ $user = $dbForProject->getDocument('users', $jwtUserId); } } @@ -451,6 +455,7 @@ Http::setResource('user', function (string $mode, Document $project, Document $c throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET); } + /** @var User $accountKeyUser */ $accountKeyUser = $dbForPlatform->getAuthorization()->skip(fn () => $dbForPlatform->getDocument('users', $accountKeyUserId)); if (! $accountKeyUser->isEmpty()) { $key = $accountKeyUser->find( @@ -594,7 +599,7 @@ Http::setResource('authorization', function () { return new Authorization(); }, []); -Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization) { +Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatform, Cache $cache, Document $project, Response $response, Publisher $publisher, Publisher $publisherFunctions, Publisher $publisherWebhooks, Event $queueForEvents, Func $queueForFunctions, Webhook $queueForWebhooks, Realtime $queueForRealtime, UsageContext $usage, Authorization $authorization, Request $request) { if ($project->isEmpty() || $project->getId() === 'console') { return $dbForPlatform; } @@ -700,7 +705,31 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $dbForProject->getCache()->purge($cacheKey); }; - $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) { + /** + * Prefix metrics with database type when applicable. + * Avoids prefixing for legacy and tablesdb types to preserve historical metrics. + */ + $getDatabaseTypePrefixedMetric = function (string $databaseType, string $metric): string { + if ( + $databaseType === '' || + $databaseType === DATABASE_TYPE_LEGACY || + $databaseType === DATABASE_TYPE_TABLESDB + ) { + return $metric; + } + + return $databaseType . '.' . $metric; + }; + + // Determine database type from request path, similar to api.php + $path = $request->getURI(); + $databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => '', + }; + + $usageDatabaseListener = function (string $event, Document $document, UsageContext $usage) use ($getDatabaseTypePrefixedMetric, $databaseType) { $value = 1; switch ($event) { @@ -732,7 +761,8 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $usage->addMetric(METRIC_SESSIONS, $value); // per project break; case $document->getCollection() === 'databases': // databases - $usage->addMetric(METRIC_DATABASES, $value); // per project + $metric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASES); + $usage->addMetric($metric, $value); // per project if ($event === Database::EVENT_DOCUMENT_DELETE) { $usage->addReduce($document); @@ -741,9 +771,11 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor case str_starts_with($document->getCollection(), 'database_') && ! str_contains($document->getCollection(), 'collection'): // collections $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; + $collectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_COLLECTIONS); + $databaseIdCollectionMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTIONS); $usage - ->addMetric(METRIC_COLLECTIONS, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_COLLECTIONS), $value); + ->addMetric($collectionMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdCollectionMetric), $value); if ($event === Database::EVENT_DOCUMENT_DELETE) { $usage->addReduce($document); @@ -753,10 +785,13 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; $collectionInternalId = $parts[3] ?? 0; + $documentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DOCUMENTS); + $databaseIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_DOCUMENTS); + $databaseIdCollectionIdDocumentsMetric = $getDatabaseTypePrefixedMetric($databaseType, METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); $usage - ->addMetric(METRIC_DOCUMENTS, $value) // per project - ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), $value) // per database - ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), $value); // per collection + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection break; case $document->getCollection() === 'buckets': // buckets $usage->addMetric(METRIC_BUCKETS, $value); // per project @@ -831,7 +866,7 @@ Http::setResource('dbForProject', function (Group $pools, Database $dbForPlatfor ->on(Database::EVENT_DOCUMENT_DELETE, 'purge-function-events-cache', fn ($event, $document) => $functionsEventsCacheListener($event, $document, $project, $database)); return $database; -}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization']); +}, ['pools', 'dbForPlatform', 'cache', 'project', 'response', 'publisher', 'publisherFunctions', 'publisherWebhooks', 'queueForEvents', 'queueForFunctions', 'queueForWebhooks', 'queueForRealtime', 'usage', 'authorization', 'request']); Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authorization $authorization) { @@ -852,6 +887,138 @@ Http::setResource('dbForPlatform', function (Group $pools, Cache $cache, Authori return $database; }, ['pools', 'cache', 'authorization']); +Http::setResource('getDatabasesDB', function (Group $pools, Cache $cache, Document $project, Request $request, UsageContext $usage, Authorization $authorization) { + + return function (Document $database) use ($pools, $cache, $project, $request, $usage, $authorization): Database { + $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); + $databaseType = $database->getAttribute('type', ''); + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + // for old databases migrated through patch script + // databaseDSN determines the adapter + $databaseDSN = new DSN('mysql://'.$databaseDSN); + } + try { + $dsn = new DSN($project->getAttribute('database')); + } catch (\InvalidArgumentException) { + // TODO: Temporary until all projects are using shared tables + $dsn = new DSN('mysql://' . $project->getAttribute('database')); + } + + $pool = $pools->get($databaseDSN->getHost()); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization) + ->setMetadata('host', \gethostname()) + ->setMetadata('project', $project->getId()) + ->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_API) + ->setMaxQueryValues(APP_DATABASE_QUERY_MAX_VALUES); + // inside pools authorization needs to be set first + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + if (\in_array($dsn->getHost(), $sharedTables)) { + $database + ->setSharedTables(true) + ->setTenant((int)$project->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $project->getSequence()); + } + $timeout = \intval($request->getHeader('x-appwrite-timeout')); + if (!empty($timeout) && Http::isDevelopment()) { + $database->setTimeout($timeout); + } + + // Register database event listeners for usage stats collection + $documentsMetric = METRIC_DOCUMENTS; + $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; + $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $documentsMetric = $databaseType. '.' .$documentsMetric; + $databaseIdDocumentsMetric = $databaseType. '.' .$databaseIdDocumentsMetric; + $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' .$databaseIdCollectionIdDocumentsMetric; + } + $database + ->on(Database::EVENT_DOCUMENT_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = 1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENT_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1; + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_CREATE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_DELETE, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = -1 * $document->getAttribute('modified', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }) + ->on(Database::EVENT_DOCUMENTS_UPSERT, 'calculate-usage', function ($event, $document) use ($usage, $documentsMetric, $databaseIdDocumentsMetric, $databaseIdCollectionIdDocumentsMetric) { + $value = $document->getAttribute('created', 0); + + if (str_starts_with($document->getCollection(), 'database_') && str_contains($document->getCollection(), '_collection_')) { + $parts = explode('_', $document->getCollection()); + $databaseInternalId = $parts[1] ?? 0; + $collectionInternalId = $parts[3] ?? 0; + $usage + ->addMetric($documentsMetric, $value) // per project + ->addMetric(str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), $value) // per database + ->addMetric(str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $collectionInternalId], $databaseIdCollectionIdDocumentsMetric), $value); // per collection + } + }); + + return $database; + }; + +}, ['pools','cache','project','request','usage','authorization']); + Http::setResource('getProjectDB', function (Group $pools, Database $dbForPlatform, $cache, Authorization $authorization) { $databases = []; @@ -1485,9 +1652,9 @@ Http::setResource('resourceToken', function ($project, $dbForProject, $request, return new Document([]); }, ['project', 'dbForProject', 'request', 'authorization']); -Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization) { - return new TransactionState($dbForProject, $authorization); -}, ['dbForProject', 'authorization']); +Http::setResource('transactionState', function (Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { + return new TransactionState($dbForProject, $authorization, $getDatabasesDB); +}, ['dbForProject', 'authorization', 'getDatabasesDB']); Http::setResource('executionsRetentionCount', function (Document $project, array $plan) { if ($project->getId() === 'console' || empty($plan)) { @@ -1496,3 +1663,10 @@ Http::setResource('executionsRetentionCount', function (Document $project, array return (int) ($plan['executionsRetentionCount'] ?? 100); }, ['project', 'plan']); + +Http::setResource('embeddingAgent', function ($register) { + $adapter = new Ollama(); + $adapter->setEndpoint(System::getEnv('_APP_EMBEDDING_ENDPOINT', 'http://ollama:11434/api/embed')); + $adapter->setTimeout((int) System::getEnv('_APP_EMBEDDING_TIMEOUT', '30000')); + return new Agent($adapter); +}, ['register']); diff --git a/app/realtime.php b/app/realtime.php index d3305ca7f8..1c453ce05b 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -518,7 +518,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $project = $consoleDatabase->getAuthorization()->skip(fn () => $consoleDatabase->getDocument('projects', $projectId)); $database = getProjectDB($project); - /** @var Appwrite\Utopia\Database\Documents\User $user */ + /** @var User $user */ $user = $database->getDocument('users', $userId); $roles = $user->getRoles($database->getAuthorization()); @@ -642,10 +642,14 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new Exception(Exception::REALTIME_POLICY_VIOLATION, 'Missing or unknown project ID'); } + $timelimit = $app->getResource('timelimit'); + $user = $app->getResource('user'); /** @var User $user */ + $logUser = $user; + if ( array_key_exists('realtime', $project->getAttribute('apis', [])) && !$project->getAttribute('apis', [])['realtime'] - && !(User::isPrivileged($authorization->getRoles()) || User::isApp($authorization->getRoles())) + && !($user->isPrivileged($authorization->getRoles()) || $user->isApp($authorization->getRoles())) ) { throw new AppwriteException(AppwriteException::GENERAL_API_DISABLED); } @@ -656,10 +660,6 @@ $server->onOpen(function (int $connection, SwooleRequest $request) use ($server, throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Project is not accessible in this region. Please make sure you are using the correct endpoint'); } - $timelimit = $app->getResource('timelimit'); - $user = $app->getResource('user'); /** @var User $user */ - $logUser = $user; - /* * Abuse Check * diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml index 0f4df352bd..741d085445 100644 --- a/app/views/install/compose.phtml +++ b/app/views/install/compose.phtml @@ -13,7 +13,7 @@ $organization = $this->getParam('organization', ''); $image = $this->getParam('image', ''); $enableAssistant = $this->getParam('enableAssistant', false); $dbService = $this->getParam('database', 'mongodb'); -$allowedDbServices = ['mariadb', 'mongodb', 'postgresql']; +$allowedDbServices = ['mariadb', 'mongodb']; if (!\in_array($dbService, $allowedDbServices, true)) { $dbService = 'mongodb'; } @@ -194,7 +194,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/'); appwrite-console: <<: *x-logging container_name: appwrite-console - image: /console:7.6.4 + image: /console:7.8.26 restart: unless-stopped networks: - appwrite diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml index 05bc1b80ed..d33838c8c6 100644 --- a/app/views/install/installer.phtml +++ b/app/views/install/installer.phtml @@ -13,7 +13,7 @@ $enabledDatabases = $enabledDatabases ?? ['mongodb', 'mariadb', 'postgresql']; $isLocalInstall = $isLocalInstall ?? false; -$cardStep = min(4, $step); +$cardStep = ($step === 5) ? 4 : $step; $stepFile = __DIR__ . "/installer/templates/steps/step-{$cardStep}.phtml"; if (!is_file($stepFile)) { $stepFile = __DIR__ . "/installer/templates/steps/step-1.phtml"; diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css index b1d8fe5089..8fd28a12a3 100644 --- a/app/views/install/installer/css/styles.css +++ b/app/views/install/installer/css/styles.css @@ -478,6 +478,10 @@ body { overflow: hidden; } +.installer-page[data-upgrade='true'] .installer-step { + min-height: 0; +} + .action-shell { display: flex; flex-direction: column; @@ -691,6 +695,19 @@ body { transform: translateY(10px); } +.install-counter { + margin-left: auto; + opacity: 0; + transition: opacity 0.2s ease; + white-space: nowrap; + user-select: none; + color: var(--fgcolor-neutral-secondary); +} + +.install-row[data-status='in-progress'] .install-counter:not(:empty) { + opacity: 1; +} + .install-row-toggle { margin-left: auto; width: 32px; @@ -897,6 +914,17 @@ body { gap: var(--gap-m); } +.install-global-actions { + display: flex; + justify-content: center; + gap: var(--gap-m); + padding: var(--space-4) 0; +} + +.install-global-actions.is-hidden { + display: none; +} + .install-error-details .button { align-self: center; margin-top: 0; @@ -1784,3 +1812,92 @@ body { gap: var(--gap-s); } } + +.migration-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--gap-l); + padding: var(--space-6); + background: var(--bgcolor-neutral-default); + border-radius: var(--border-radius-m); + outline: var(--border-width-s) solid var(--border-neutral); + outline-offset: calc(var(--border-width-s) * -1); + cursor: pointer; + transition: outline-color 0.15s ease-in-out; +} + +.migration-option:hover { + outline-color: var(--border-neutral-stronger); +} + +.migration-option-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.migration-switch { + flex-shrink: 0; +} + +.migration-switch-track { + position: relative; + display: block; + width: 32px; + height: 20px; + border-radius: 10px; + background: var(--bgcolor-neutral-invert-weaker); + transition: background 0.15s ease-in-out; +} + +.migration-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bgcolor-neutral-primary); + transition: transform 0.15s ease-in-out; +} + +#run-migration:checked ~ .migration-switch-track { + background: var(--bgcolor-neutral-invert-weak); +} + +#run-migration:checked ~ .migration-switch-track .migration-switch-thumb { + transform: translateX(12px); +} + +#run-migration:focus-visible ~ .migration-switch-track { + box-shadow: 0 0 0 var(--border-width-l) var(--border-focus); +} + +.migration-hint { + display: flex; + align-items: flex-start; + gap: var(--gap-s); + padding: 0 var(--space-2); +} + +.migration-hint-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--fgcolor-neutral-tertiary); + margin-top: 1px; +} + +.migration-hint-icon svg { + width: 100%; + height: 100%; +} + +.migration-code { + padding: 1px 4px; + border-radius: var(--border-radius-xs, 4px); + background: var(--bgcolor-neutral-secondary); + font-family: monospace; + font-size: inherit; +} diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js index 463b7f6221..07ec7bb1ef 100644 --- a/app/views/install/installer/js/installer.js +++ b/app/views/install/installer/js/installer.js @@ -12,7 +12,7 @@ const { validateInstallRequest } = window.InstallerStepsProgress || {}; const isUpgrade = document.body?.dataset.upgrade === 'true'; - const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5]; + const stepFlow = isUpgrade ? [1, 6, 4, 5] : [1, 2, 3, 4, 5]; const cardSteps = stepFlow.filter((step) => step !== 5); const normalizeStep = (step) => { @@ -53,7 +53,7 @@ let pendingStep = null; let pendingPushState = false; - const clampStep = (step) => Math.max(1, Math.min(5, step)); + const clampStep = (step) => Math.max(1, Math.min(6, step)); const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.()); const scrollToFirstError = (panel) => { @@ -399,11 +399,18 @@ } } } - if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') { - const isValid = await validateInstallRequest(); - if (!isValid) { - return; + if (action === 'next' && String(target) === '5') { + if (typeof validateInstallRequest === 'function') { + const isValid = await validateInstallRequest(); + if (!isValid) { + return; + } } + // Clear stale install data from previous runs so initStep5 + // starts a fresh install instead of trying to resume. + const { clearInstallLock, clearInstallId } = window.InstallerStepsState || {}; + clearInstallLock?.(); + clearInstallId?.(); } if (isInstallLocked() && Number(target) !== 5) { requestStep(5, true); diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js index c531ecddce..6f215da899 100644 --- a/app/views/install/installer/js/modules/context.js +++ b/app/views/install/installer/js/modules/context.js @@ -13,7 +13,10 @@ DOCKER_COMPOSE: 'docker-compose', ENV_VARS: 'env-vars', DOCKER_CONTAINERS: 'docker-containers', - ACCOUNT_SETUP: 'account-setup' + ACCOUNT_SETUP: 'account-setup', + MIGRATION: 'migration', + SSL_CERTIFICATE: 'ssl-certificate', + REDIRECT: 'redirect' }); const STATUS = Object.freeze({ @@ -50,6 +53,11 @@ id: STEP_IDS.DOCKER_CONTAINERS, inProgress: 'Restarting Docker containers...', done: 'Docker containers restarted' + }, + { + id: STEP_IDS.MIGRATION, + inProgress: 'Running database migration...', + done: 'Database migration completed' } ] : [ { @@ -75,7 +83,7 @@ { id: STEP_IDS.ACCOUNT_SETUP, inProgress: 'Creating Appwrite account...', - done: 'Appwrite account created (redirecting...)' + done: 'Appwrite account created' } ]); @@ -93,7 +101,7 @@ const clampStep = (step) => { const numeric = Number(step); if (Number.isNaN(numeric)) return 1; - return Math.max(1, Math.min(5, numeric)); + return Math.max(1, Math.min(6, numeric)); }; window.InstallerStepsContext = Object.freeze({ diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js index 7f7b23e3fc..7c36fd7951 100644 --- a/app/views/install/installer/js/modules/progress.js +++ b/app/views/install/installer/js/modules/progress.js @@ -21,7 +21,7 @@ storeInstallId, clearInstallId } = window.InstallerStepsState || {}; - const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {}; + const { extractHostname, isLocalHost, isIPAddress } = window.InstallerStepsValidation || {}; const { generateSecretKey } = window.InstallerStepsUI || {}; const { showToast } = window.InstallerToast || {}; @@ -111,10 +111,10 @@ return normalized.summary || 'Installation failed.'; } if (status === STATUS.COMPLETED) return step.done; - return step.inProgress; + return message || step.inProgress; }; - const updateInstallRow = (row, step, status, message) => { + const updateInstallRow = (row, step, status, message, details) => { if (!row || !step) return; row.dataset.status = status; row.dataset.step = step.id; @@ -138,6 +138,15 @@ } } + const counter = row.querySelector('[data-install-counter]'); + if (counter) { + const started = details?.containerStarted ?? 0; + const total = details?.containerTotal; + counter.textContent = (status === STATUS.IN_PROGRESS && total > 0 && started < total) + ? `${started}/${total}` + : ''; + } + // Show/hide "Navigate to Console" button for account setup errors const consoleBtn = row.querySelector('[data-install-console]'); if (consoleBtn) { @@ -251,7 +260,7 @@ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); }; - const buildRedirectUrl = () => { + const buildRedirectUrl = (protocol) => { const dataset = getBodyDataset?.() ?? {}; const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); if (!rawDomain) return ''; @@ -266,22 +275,53 @@ } else if (normalizedHost === 'traefik') { host = rawDomain.replace(hostForProtocol, 'localhost'); } - let protocol = 'http'; - let port = httpPort; - if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) { - protocol = 'https'; - port = httpsPort; - } - if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) { + const port = protocol === 'https' ? httpsPort : httpPort; + const defaultPort = protocol === 'https' ? '443' : '80'; + if (!hasPort && port && port !== defaultPort) { host = `${host}:${port}`; } return `${protocol}://${host}`; }; - const redirectToApp = () => { - const url = buildRedirectUrl(); + const normalizeHostname = (rawDomain) => { + const hostname = extractHostname?.(rawDomain)?.toLowerCase?.() ?? ''; + if (hostname === '0.0.0.0' || hostname === 'traefik') return 'localhost'; + return hostname; + }; + + const canUseHttps = () => { + const dataset = getBodyDataset?.() ?? {}; + const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); + const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim(); + if (!httpsPort || httpsPort === '0') return false; + const hostname = normalizeHostname(rawDomain); + return !isLocalHost?.(hostname) && !isIPAddress?.(hostname); + }; + + const pollCertificate = async (domain, port, maxAttempts, intervalMs) => { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch( + `/install/certificate?domain=${encodeURIComponent(domain)}&port=${encodeURIComponent(port)}`, + { cache: 'no-store' } + ); + if (response.ok) { + const data = await response.json(); + if (data.ready) return true; + } + } catch { + // Installer server may have shut down + } + if (i < maxAttempts - 1) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + } + return false; + }; + + const redirectToApp = (protocol) => { + const url = buildRedirectUrl(protocol); if (!url) return; - // Fire-and-forget: tell the installer server it can shut down fetch('/install/shutdown', { method: 'POST', headers: withCsrfHeader() }).catch(() => {}); window.location.href = url; }; @@ -318,7 +358,7 @@ const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost'; const normalizedHttpPort = (formState?.httpPort || '').trim() || '80'; const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443'; - const normalizedEmail = (formState?.emailCertificates || '').trim(); + const normalizedEmail = (formState?.emailCertificates || '').trim() || (formState?.accountEmail || '').trim(); const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim(); const normalizedAccountEmail = (formState?.accountEmail || '').trim(); const normalizedAccountPassword = (formState?.accountPassword || '').trim(); @@ -333,7 +373,8 @@ opensslKey: (formState?.opensslKey || '').trim(), assistantOpenAIKey: normalizedAssistantKey, accountEmail: normalizedAccountEmail, - accountPassword: normalizedAccountPassword + accountPassword: normalizedAccountPassword, + migrate: formState?.migrate ?? false }; }; @@ -406,6 +447,7 @@ const initStep5 = (root) => { if (!root) return; + let resolvedProtocol = 'http'; if (activeInstall?.controller) { activeInstall.controller.abort(); @@ -497,7 +539,7 @@ if (!state) return; const row = ensureRow(step); if (row) { - updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message); + updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message, state.details); if (state.status === STATUS?.ERROR) { updateInstallErrorDetails(row, { message: state.message, @@ -547,6 +589,9 @@ } } } + if (payload.status === STATUS.ERROR) { + showGlobalActions(); + } scheduleFallback(); }; @@ -584,6 +629,7 @@ const applySnapshot = (snapshot) => { if (!snapshot || !snapshot.steps) return; + let hasErrors = false; INSTALLATION_STEPS.forEach((step) => { const detail = snapshot.steps[step.id]; if (!detail) return; @@ -592,8 +638,14 @@ message: detail.message, details: snapshot.details?.[step.id] }); + if (detail.status === STATUS.ERROR) { + hasErrors = true; + } }); renderProgress(); + if (hasErrors) { + showGlobalActions(); + } }; const checkAllCompleted = () => { @@ -605,9 +657,7 @@ const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); const sessionDetails = sseSessionDetails || accountState?.details; finalizeInstall(); - notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { - setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); - }); + startSslCheck(sessionDetails); }; const startPolling = () => { @@ -644,6 +694,78 @@ } stopSyncedSpinnerRotation(); setUnloadGuard(false); + clearInstallLock?.(); + }; + + const SSL_STEP = { + id: STEP_IDS.SSL_CERTIFICATE, + inProgress: 'Generating SSL certificate...', + done: 'SSL certificate verified' + }; + + const REDIRECT_STEP = { + id: STEP_IDS.REDIRECT, + inProgress: 'Redirecting to console...', + done: 'Redirecting to console...' + }; + + const showRedirectStep = (sessionDetails, protocol) => { + animatePanelHeight(() => { + progressState.set(REDIRECT_STEP.id, { + status: STATUS.IN_PROGRESS, + message: REDIRECT_STEP.inProgress + }); + const row = ensureRow(REDIRECT_STEP); + if (row) { + updateInstallRow(row, REDIRECT_STEP, STATUS.IN_PROGRESS, REDIRECT_STEP.inProgress); + } + }); + startSyncedSpinnerRotation(list); + + const completeId = activeInstall?.installId || getStoredInstallId?.(); + notifyInstallComplete(completeId, sessionDetails).finally(() => { + setTimeout(() => redirectToApp(protocol), TIMINGS?.redirectDelay ?? 0); + }); + }; + + const startSslCheck = (sessionDetails) => { + if (!canUseHttps()) { + showRedirectStep(sessionDetails, 'http'); + return; + } + + animatePanelHeight(() => { + progressState.set(SSL_STEP.id, { + status: STATUS.IN_PROGRESS, + message: SSL_STEP.inProgress + }); + const row = ensureRow(SSL_STEP); + if (row) { + updateInstallRow(row, SSL_STEP, STATUS.IN_PROGRESS, SSL_STEP.inProgress); + } + }); + startSyncedSpinnerRotation(list); + + const dataset = getBodyDataset?.() ?? {}; + const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim(); + const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '443').trim(); + const domain = normalizeHostname(rawDomain); + pollCertificate(domain, httpsPort, 15, 2000).then((ready) => { + stopSyncedSpinnerRotation(); + const certMessage = ready ? SSL_STEP.done : 'Certificate not ready, continuing over HTTP'; + animatePanelHeight(() => { + progressState.set(SSL_STEP.id, { + status: STATUS.COMPLETED, + message: certMessage + }); + const row = ensureRow(SSL_STEP); + if (row) { + updateInstallRow(row, SSL_STEP, STATUS.COMPLETED, certMessage); + } + }); + resolvedProtocol = ready ? 'https' : 'http'; + showRedirectStep(sessionDetails, resolvedProtocol); + }); }; const startInstallStream = async (installId, options = {}) => { @@ -746,9 +868,7 @@ const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP); const sessionDetails = sseSessionDetails || accountState?.details; finalizeInstall(); - notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => { - setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0); - }); + startSslCheck(sessionDetails); return; } if (event === SSE_EVENTS.ERROR) { @@ -792,9 +912,29 @@ } }; + const isSnapshotTerminal = (snapshot) => { + if (!snapshot?.steps) return 'empty'; + const stepEntries = Object.values(snapshot.steps); + if (stepEntries.length === 0) return 'empty'; + const hasError = stepEntries.some((s) => s.status === STATUS.ERROR); + if (hasError) return 'error'; + const allCompleted = INSTALLATION_STEPS.every((step) => { + const detail = snapshot.steps[step.id]; + return detail && detail.status === STATUS.COMPLETED; + }); + if (allCompleted) return 'completed'; + return false; + }; + const resumeInstall = async (installId) => { const snapshot = await fetchInstallStatus(installId); - if (!snapshot) return false; + const terminal = isSnapshotTerminal(snapshot); + if (!snapshot || terminal) { + if (terminal === 'completed') { + return 'completed'; + } + return false; + } activeInstall = { installId, controller: new AbortController(), @@ -857,7 +997,7 @@ const retryButton = event.target.closest('[data-install-retry]'); if (consoleButton) { - redirectToApp(); + redirectToApp(resolvedProtocol); return; } @@ -868,6 +1008,60 @@ } }); + const globalActions = root.querySelector('[data-install-global-actions]'); + + const showGlobalActions = () => { + if (globalActions) { + globalActions.classList.remove('is-hidden'); + } + }; + + const performReset = async (hard) => { + const installId = activeInstall?.installId || getInstallLock?.()?.installId || getStoredInstallId?.(); + + try { + const res = await fetch('/install/reset', { + method: 'POST', + headers: withCsrfHeader({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ installId: installId || '', hard }) + }); + if (hard && !res.ok) { + const data = await res.json().catch(() => ({})); + showToast?.({ + status: 'error', + title: 'Reset failed', + description: data?.message || 'Could not stop containers. Try running "docker compose down -v" manually.', + dismissible: true + }); + return; + } + } catch (e) { + console.error('Reset request failed:', e); + } + + clearInstallLock?.(); + clearInstallId?.(); + cleanupInstallFlow(); + window.location.href = '/?step=1'; + }; + + const startOverButton = root.querySelector('[data-install-start-over]'); + if (startOverButton) { + startOverButton.addEventListener('click', () => performReset(false)); + } + + const hardResetButton = root.querySelector('[data-install-hard-reset]'); + if (hardResetButton) { + hardResetButton.addEventListener('click', () => { + const confirmed = window.confirm( + 'This will stop all containers, remove all volumes (including database data, uploads, and certificates), and delete configuration files.\n\nThis action cannot be undone. Continue?' + ); + if (confirmed) { + performReset(true); + } + }); + } + // When the user switches back to this tab, check if installation // finished while the tab was in the background. document.addEventListener('visibilitychange', () => { @@ -876,22 +1070,45 @@ } }); - const lock = getInstallLock?.(); - const existingInstallId = lock?.installId || getStoredInstallId?.(); - if (existingInstallId) { - resumeInstall(existingInstallId).then((resumed) => { - if (!resumed) { - clearInstallId?.(); - clearInstallLock?.(); - const newInstallId = generateInstallId(); - storeInstallId?.(newInstallId); - startInstallStream(newInstallId); - } - }); - } else { + const startFreshInstall = () => { + clearInstallId?.(); + clearInstallLock?.(); const newInstallId = generateInstallId(); storeInstallId?.(newInstallId); startInstallStream(newInstallId); + }; + + const recoverToLastStep = () => { + clearInstallId?.(); + clearInstallLock?.(); + const url = new URL(window.location.href); + const lastStep = url.searchParams.get('step'); + // Stay on the current URL so the user keeps their place; + // only navigate away if we're already on step 5 (the + // progress screen) since there's nothing to show. + if (!lastStep || String(lastStep) === '5') { + window.location.href = '/?step=1'; + } + }; + + const lock = getInstallLock?.(); + const existingInstallId = lock?.installId || getStoredInstallId?.(); + if (existingInstallId) { + resumeInstall(existingInstallId).then((result) => { + if (result === 'completed') { + // Install already finished — redirect to console + // instead of bouncing back to step 1. + stopSyncedSpinnerRotation(); + setUnloadGuard(false); + clearInstallLock?.(); + clearInstallId?.(); + startSslCheck(null); + } else if (!result) { + recoverToLastStep(); + } + }); + } else { + startFreshInstall(); } }; diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js index 9fcf9969a8..3c7fcd2427 100644 --- a/app/views/install/installer/js/modules/state.js +++ b/app/views/install/installer/js/modules/state.js @@ -7,6 +7,8 @@ const INSTALL_LOCK_KEY = 'appwrite-install-lock'; const INSTALL_ID_KEY = 'appwrite-install-id'; + const INSTALL_LOCK_LOCAL_KEY = 'appwrite-install-lock-backup'; + const INSTALL_ID_LOCAL_KEY = 'appwrite-install-id-backup'; const formState = { appDomain: null, @@ -55,13 +57,24 @@ const getInstallLock = () => { try { const raw = sessionStorage.getItem(INSTALL_LOCK_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (!parsed || typeof parsed !== 'object') return null; - return parsed; - } catch (error) { - return null; - } + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') return parsed; + } + } catch (error) {} + + try { + const raw = localStorage.getItem(INSTALL_LOCK_LOCAL_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + sessionStorage.setItem(INSTALL_LOCK_KEY, raw); + return parsed; + } + } + } catch (error) {} + + return null; }; const setInstallLock = (installId, payload) => { @@ -79,6 +92,9 @@ try { sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock)); } catch (error) {} + try { + localStorage.setItem(INSTALL_LOCK_LOCAL_KEY, JSON.stringify(lock)); + } catch (error) {} if (document.body) { document.body.dataset.installLocked = 'true'; } @@ -89,6 +105,9 @@ try { sessionStorage.removeItem(INSTALL_LOCK_KEY); } catch (error) {} + try { + localStorage.removeItem(INSTALL_LOCK_LOCAL_KEY); + } catch (error) {} if (document.body) { delete document.body.dataset.installLocked; } @@ -121,22 +140,31 @@ const getStoredInstallId = () => { try { - return sessionStorage.getItem(INSTALL_ID_KEY); - } catch (error) { - return null; - } + const val = sessionStorage.getItem(INSTALL_ID_KEY); + if (val) return val; + } catch (error) {} + try { + return localStorage.getItem(INSTALL_ID_LOCAL_KEY); + } catch (error) {} + return null; }; const storeInstallId = (installId) => { try { sessionStorage.setItem(INSTALL_ID_KEY, installId); } catch (error) {} + try { + localStorage.setItem(INSTALL_ID_LOCAL_KEY, installId); + } catch (error) {} }; const clearInstallId = () => { try { sessionStorage.removeItem(INSTALL_ID_KEY); } catch (error) {} + try { + localStorage.removeItem(INSTALL_ID_LOCAL_KEY); + } catch (error) {} }; window.InstallerStepsState = { diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js index bde4cb7c44..a41a657602 100644 --- a/app/views/install/installer/js/modules/ui.js +++ b/app/views/install/installer/js/modules/ui.js @@ -240,6 +240,9 @@ if (key === 'database') { value = toDatabaseLabel(formState?.database); } + if (key === 'emailCertificates' && !value) { + value = formState?.accountEmail; + } if (value) { node.textContent = value; } diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js index 13ab60ef4e..daa66eb8d6 100644 --- a/app/views/install/installer/js/modules/validation.js +++ b/app/views/install/installer/js/modules/validation.js @@ -106,12 +106,18 @@ return LOCAL_HOSTS.has(normalized); }; + const isIPAddress = (host) => { + if (!host) return false; + return isValidIPv4(host) || isValidIPv6(host); + }; + window.InstallerStepsValidation = { isValidEmail, isValidPort, isValidPassword, isValidHostnameInput, extractHostname, - isLocalHost + isLocalHost, + isIPAddress }; })(); diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js index 2a71d075cc..b34389b561 100644 --- a/app/views/install/installer/js/steps.js +++ b/app/views/install/installer/js/steps.js @@ -329,6 +329,30 @@ } }; + const initStep6 = (root) => { + if (!root) return; + syncInstallLockFlag?.(); + applyLockPayload?.(); + applyBodyDefaults?.(); + + const checkbox = root.querySelector('#run-migration'); + if (checkbox) { + if (formState.migrate !== undefined) { + checkbox.checked = formState.migrate; + } else { + formState.migrate = checkbox.checked; + } + checkbox.addEventListener('change', () => { + formState.migrate = checkbox.checked; + dispatchStateChange?.('migrate'); + }); + } + + if (isInstallLocked?.()) { + disableControls?.(root); + } + }; + const initStep = (step, container) => { if (!container) return; const root = container.querySelector('.step-layout') || container; @@ -346,6 +370,7 @@ if (normalized === 3) initStep3(root); if (normalized === 4) initStep4(root); if (normalized === 5) Progress.initStep5?.(root); + if (normalized === 6) initStep6(root); }; window.InstallerSteps = { @@ -390,10 +415,7 @@ if (!parsePort(httpPort, 'HTTP')) valid = false; if (!parsePort(httpsPort, 'HTTPS')) valid = false; - if (!sslEmail || !sslEmail.value.trim()) { - setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates'); - valid = false; - } else if (!isValidEmail?.(sslEmail.value.trim())) { + if (sslEmail && sslEmail.value.trim() && !isValidEmail?.(sslEmail.value.trim())) { setFieldError?.(sslEmail, 'Please enter a valid email address'); valid = false; } diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml index 07dc865257..8468de30f4 100644 --- a/app/views/install/installer/templates/steps/step-4.phtml +++ b/app/views/install/installer/templates/steps/step-4.phtml @@ -62,12 +62,14 @@ $badgeClass = $defaultSecretKey !== '' ? 'badge-success' : 'badge-warning'; Disabled
Appwrite Assistant
+
Secret API key
+ diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml index 8fa810b259..c18c3ea748 100644 --- a/app/views/install/installer/templates/steps/step-5.phtml +++ b/app/views/install/installer/templates/steps/step-5.phtml @@ -6,7 +6,7 @@ $isUpgrade = $isUpgrade ?? false;
- +
@@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false;
+ @@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false; + + diff --git a/app/views/install/installer/templates/steps/step-6.phtml b/app/views/install/installer/templates/steps/step-6.phtml new file mode 100644 index 0000000000..9a8838ae3a --- /dev/null +++ b/app/views/install/installer/templates/steps/step-6.phtml @@ -0,0 +1,37 @@ + +
+
+
+

Database migration

+

+ Run database migration after the update to apply schema changes. +

+
+ +
+ + +
+ + + + + To run manually later: docker compose exec appwrite migrate + +
+
+
+
diff --git a/app/worker.php b/app/worker.php index 840231f16c..71446ee94f 100644 --- a/app/worker.php +++ b/app/worker.php @@ -220,6 +220,60 @@ Server::setResource('getLogsDB', function (Group $pools, Cache $cache, Authoriza }; }, ['pools', 'cache', 'authorization']); +Server::setResource('getDatabasesDB', function (Cache $cache, Registry $register, Document $project, Authorization $authorization) { + return function (Document $database, ?Document $projectDocument = null) use ($cache, $register, $project, $authorization): Database { + $projectDocument ??= $project; + $databaseDSN = $database->getAttribute('database', $project->getAttribute('database', '')); + $databaseType = $database->getAttribute('type', ''); + + // Backwards‑compatibility: older or seeded legacy databases may not have a DSN stored + // in the "database" attribute. In that case, fall back to the project's database DSN. + if ($databaseDSN === '') { + $databaseDSN = $projectDocument->getAttribute('database', ''); + } + + try { + $databaseDSN = new DSN($databaseDSN); + } catch (\InvalidArgumentException) { + $databaseDSN = new DSN('mysql://'.$databaseDSN); + } + + try { + $dsn = new DSN($projectDocument->getAttribute('database')); + } catch (\InvalidArgumentException) { + // Temporary fallback until all projects use shared tables + $dsn = new DSN('mysql://' . $projectDocument->getAttribute('database')); + } + + $pools = $register->get('pools'); + $pool = $pools->get($databaseDSN->getHost()); + + $adapter = new DatabasePool($pool); + $database = new Database($adapter, $cache); + $database + ->setDatabase(APP_DATABASE) + ->setAuthorization($authorization); + $database->getAdapter()->setSupportForAttributes($databaseType !== DOCUMENTSDB); + + $sharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + + if (\in_array($dsn->getHost(), $sharedTables, true)) { + $database + ->setSharedTables(true) + ->setTenant((int) $projectDocument->getSequence()) + ->setNamespace($dsn->getParam('namespace')); + } else { + $database + ->setSharedTables(false) + ->setTenant(null) + ->setNamespace('_' . $projectDocument->getSequence()); + } + + $database->setTimeout(APP_DATABASE_TIMEOUT_MILLISECONDS_WORKER); + return $database; + }; +}, ['cache', 'register', 'project', 'authorization']); + Server::setResource('abuseRetention', function () { return time() - (int) System::getEnv('_APP_MAINTENANCE_RETENTION_ABUSE', 86400); // 1 day }); diff --git a/composer.json b/composer.json index 2448a55522..1a530bfc5b 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,9 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", - "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "check": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker" }, @@ -52,7 +52,6 @@ "appwrite/php-runtimes": "0.19.*", "appwrite/php-clamav": "2.0.*", "utopia-php/abuse": "1.2.*", - "utopia-php/agents": "1.2.*", "utopia-php/analytics": "0.15.*", "utopia-php/audit": "2.2.*", "utopia-php/auth": "0.5.*", @@ -62,6 +61,7 @@ "utopia-php/config": "1.*", "utopia-php/console": "0.1.*", "utopia-php/database": "5.*", + "utopia-php/agents": "1.*", "utopia-php/detector": "0.2.*", "utopia-php/domains": "1.*", "utopia-php/emails": "0.6.*", @@ -73,7 +73,7 @@ "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", - "utopia-php/migration": "1.7.*", + "utopia-php/migration": "1.9.*", "utopia-php/platform": "0.7.*", "utopia-php/pools": "1.*", "utopia-php/span": "1.1.*", @@ -84,7 +84,7 @@ "utopia-php/storage": "1.0.*", "utopia-php/system": "0.10.*", "utopia-php/telemetry": "0.2.*", - "utopia-php/vcs": "2.*", + "utopia-php/vcs": "3.*", "utopia-php/websocket": "1.0.*", "matomo/device-detector": "6.4.*", "dragonmantank/cron-expression": "3.4.*", @@ -97,12 +97,6 @@ "enshrined/svg-sanitize": "0.22.*", "utopia-php/di": "0.1.0" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/utopia-php/database" - } - ], "require-dev": { "ext-fileinfo": "*", "appwrite/sdk-generator": "*", @@ -114,18 +108,11 @@ "czproject/git-php": "4.*", "laravel/pint": "1.*" }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/utopia-php/database" - } - ], "provide": { "ext-phpiredis": "*" }, "config": { "platform": { - "php": "8.3" }, "allow-plugins": { "php-http/discovery": true, diff --git a/composer.lock b/composer.lock index 50b317c811..0a79c5bee3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1404c8821e43b3fe92e06a8ed658ed26", + "content-hash": "b5261855586680e467168f527e0634ae", "packages": [ { "name": "adhocore/jwt", @@ -161,16 +161,16 @@ }, { "name": "appwrite/php-runtimes", - "version": "0.19.4", + "version": "0.19.5", "source": { "type": "git", "url": "https://github.com/appwrite/runtimes.git", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b" + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/runtimes/zipball/eea9d1b3ca2540eab623b419c8afde09ef406c0b", - "reference": "eea9d1b3ca2540eab623b419c8afde09ef406c0b", + "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546", + "reference": "aa2f7760cd0493c0880209b92df812c9386b3546", "shasum": "" }, "require": { @@ -210,9 +210,9 @@ ], "support": { "issues": "https://github.com/appwrite/runtimes/issues", - "source": "https://github.com/appwrite/runtimes/tree/0.19.4" + "source": "https://github.com/appwrite/runtimes/tree/0.19.5" }, - "time": "2026-02-17T10:04:39+00:00" + "time": "2026-04-01T01:39:23+00:00" }, { "name": "brick/math", @@ -1226,16 +1226,16 @@ }, { "name": "open-telemetry/api", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad" + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/df5197c6fd0ddd8e9883b87de042d9341300e2ad", - "reference": "df5197c6fd0ddd8e9883b87de042d9341300e2ad", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/6f8d237ce2c304ca85f31970f788e7f074d147be", + "reference": "6f8d237ce2c304ca85f31970f788e7f074d147be", "shasum": "" }, "require": { @@ -1292,20 +1292,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-01-21T04:14:03+00:00" + "time": "2026-02-25T13:24:05+00:00" }, { "name": "open-telemetry/context", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf" + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/d4c4470b541ce72000d18c339cfee633e4c8e0cf", - "reference": "d4c4470b541ce72000d18c339cfee633e4c8e0cf", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/3c414b246e0dabb7d6145404e6a5e4536ca18d07", + "reference": "3c414b246e0dabb7d6145404e6a5e4536ca18d07", "shasum": "" }, "require": { @@ -1347,11 +1347,11 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-19T00:05:49+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/exporter-otlp", @@ -1419,16 +1419,16 @@ }, { "name": "open-telemetry/gen-otlp-protobuf", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/gen-otlp-protobuf.git", - "reference": "673af5b06545b513466081884b47ef15a536edde" + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/673af5b06545b513466081884b47ef15a536edde", - "reference": "673af5b06545b513466081884b47ef15a536edde", + "url": "https://api.github.com/repos/opentelemetry-php/gen-otlp-protobuf/zipball/a229cf161d42001d64c8f21e8f678581fe1c66b9", + "reference": "a229cf161d42001d64c8f21e8f678581fe1c66b9", "shasum": "" }, "require": { @@ -1474,30 +1474,30 @@ ], "support": { "chat": "https://app.slack.com/client/T08PSQ7BQ/C01NFPCV44V", - "docs": "https://opentelemetry.io/docs/php", + "docs": "https://opentelemetry.io/docs/languages/php", "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-09-17T23:10:12+00:00" + "time": "2025-10-19T06:44:33+00:00" }, { "name": "open-telemetry/sdk", - "version": "1.13.0", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1" + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/c76f91203bf7ef98ab3f4e0a82ca21699af185e1", - "reference": "c76f91203bf7ef98ab3f4e0a82ca21699af185e1", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/6e3d0ce93e76555dd5e2f1d19443ff45b990e410", + "reference": "6e3d0ce93e76555dd5e2f1d19443ff45b990e410", "shasum": "" }, "require": { "ext-json": "*", "nyholm/psr7-server": "^1.1", - "open-telemetry/api": "^1.7", + "open-telemetry/api": "^1.8", "open-telemetry/context": "^1.4", "open-telemetry/sem-conv": "^1.0", "php": "^8.1", @@ -1575,7 +1575,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2026-01-28T11:38:11+00:00" + "time": "2026-03-21T11:50:01+00:00" }, { "name": "open-telemetry/sem-conv", @@ -3403,16 +3403,16 @@ }, { "name": "utopia-php/agents", - "version": "1.2.1", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/utopia-php/agents.git", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e" + "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/agents/zipball/052227953678a30ecc4b5467401fcb0b2386471e", - "reference": "052227953678a30ecc4b5467401fcb0b2386471e", + "url": "https://api.github.com/repos/utopia-php/agents/zipball/06064fd9fb19b77ae45a12ec7bcbc17670912c30", + "reference": "06064fd9fb19b77ae45a12ec7bcbc17670912c30", "shasum": "" }, "require": { @@ -3450,9 +3450,9 @@ ], "support": { "issues": "https://github.com/utopia-php/agents/issues", - "source": "https://github.com/utopia-php/agents/tree/1.2.1" + "source": "https://github.com/utopia-php/agents/tree/1.3.0" }, - "time": "2026-02-24T06:03:55+00:00" + "time": "2026-03-26T03:51:11+00:00" }, { "name": "utopia-php/analytics", @@ -3889,38 +3889,7 @@ "Utopia\\Database\\": "src/Database" } }, - "autoload-dev": { - "psr-4": { - "Tests\\E2E\\": "tests/e2e", - "Tests\\Unit\\": "tests/unit" - } - }, - "scripts": { - "build": [ - "Composer\\Config::disableProcessTimeout", - "docker compose build" - ], - "start": [ - "Composer\\Config::disableProcessTimeout", - "docker compose up -d" - ], - "test": [ - "Composer\\Config::disableProcessTimeout", - "docker compose exec tests vendor/bin/phpunit --configuration phpunit.xml" - ], - "lint": [ - "php -d memory_limit=2G ./vendor/bin/pint --test" - ], - "format": [ - "php -d memory_limit=2G ./vendor/bin/pint" - ], - "check": [ - "./vendor/bin/phpstan analyse --level 7 src tests --memory-limit 2G" - ], - "coverage": [ - "./vendor/bin/coverage-check ./tmp/clover.xml 90" - ] - }, + "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], @@ -3933,8 +3902,8 @@ "utopia" ], "support": { - "source": "https://github.com/utopia-php/database/tree/5.3.17", - "issues": "https://github.com/utopia-php/database/issues" + "issues": "https://github.com/utopia-php/database/issues", + "source": "https://github.com/utopia-php/database/tree/5.3.17" }, "time": "2026-03-20T01:18:52+00:00" }, @@ -4033,16 +4002,16 @@ }, { "name": "utopia-php/dns", - "version": "1.6.5", + "version": "1.6.6", "source": { "type": "git", "url": "https://github.com/utopia-php/dns.git", - "reference": "574327f0f5fabefa7048030c5634cde33ad10640" + "reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/dns/zipball/574327f0f5fabefa7048030c5634cde33ad10640", - "reference": "574327f0f5fabefa7048030c5634cde33ad10640", + "url": "https://api.github.com/repos/utopia-php/dns/zipball/917901ecfe5f09a540e4f689b6cbb80b9f55035d", + "reference": "917901ecfe5f09a540e4f689b6cbb80b9f55035d", "shasum": "" }, "require": { @@ -4084,9 +4053,9 @@ ], "support": { "issues": "https://github.com/utopia-php/dns/issues", - "source": "https://github.com/utopia-php/dns/tree/1.6.5" + "source": "https://github.com/utopia-php/dns/tree/1.6.6" }, - "time": "2026-02-19T16:06:46+00:00" + "time": "2026-03-27T11:13:50+00:00" }, { "name": "utopia-php/domains", @@ -4549,16 +4518,16 @@ }, { "name": "utopia-php/migration", - "version": "1.7.0", + "version": "1.9.1", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "97583ae502e40621ea91a71de19d053c5ae2e558" + "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/97583ae502e40621ea91a71de19d053c5ae2e558", - "reference": "97583ae502e40621ea91a71de19d053c5ae2e558", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", "shasum": "" }, "require": { @@ -4598,9 +4567,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.7.0" + "source": "https://github.com/utopia-php/migration/tree/1.9.1" }, - "time": "2026-03-10T06:36:27+00:00" + "time": "2026-03-25T07:05:27+00:00" }, { "name": "utopia-php/mongo", @@ -5247,22 +5216,23 @@ }, { "name": "utopia-php/vcs", - "version": "2.0.2", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/vcs.git", - "reference": "5769679308bad498f2777547d48ab332166c4c0b" + "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/vcs/zipball/5769679308bad498f2777547d48ab332166c4c0b", - "reference": "5769679308bad498f2777547d48ab332166c4c0b", + "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af", + "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af", "shasum": "" }, "require": { "adhocore/jwt": "^1.1", "php": ">=8.0", - "utopia-php/cache": "1.0.*" + "utopia-php/cache": "1.0.*", + "utopia-php/fetch": "0.5.*" }, "require-dev": { "laravel/pint": "1.*.*", @@ -5289,9 +5259,9 @@ ], "support": { "issues": "https://github.com/utopia-php/vcs/issues", - "source": "https://github.com/utopia-php/vcs/tree/2.0.2" + "source": "https://github.com/utopia-php/vcs/tree/3.1.0" }, - "time": "2026-03-13T15:25:16+00:00" + "time": "2026-03-24T08:49:14+00:00" }, { "name": "utopia-php/websocket", @@ -5469,16 +5439,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.11.11", + "version": "1.14.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "cfc37c85161a5515af4cd2f9885a811f51a2483a" + "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/cfc37c85161a5515af4cd2f9885a811f51a2483a", - "reference": "cfc37c85161a5515af4cd2f9885a811f51a2483a", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/7e7e257b10a8c1384a237e7d8d73452e2108901e", + "reference": "7e7e257b10a8c1384a237e7d8d73452e2108901e", "shasum": "" }, "require": { @@ -5514,9 +5484,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.11.11" + "source": "https://github.com/appwrite/sdk-generator/tree/1.14.0" }, - "time": "2026-03-19T16:21:03+00:00" + "time": "2026-03-26T12:50:11+00:00" }, { "name": "brianium/paratest", @@ -6225,11 +6195,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.42", + "version": "2.1.44", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0", - "reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218", + "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218", "shasum": "" }, "require": { @@ -6274,7 +6244,7 @@ "type": "github" } ], - "time": "2026-03-17T14:58:32+00:00" + "time": "2026-03-25T17:34:21+00:00" }, { "name": "phpunit/php-code-coverage", @@ -8486,8 +8456,5 @@ "platform-dev": { "ext-fileinfo": "*" }, - "platform-overrides": { - "php": "8.3" - }, "plugin-api-version": "2.9.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 7d64dfa867..aa2bfdd16a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,6 +112,8 @@ services: condition: service_healthy coredns: condition: service_started + ollama: + condition: service_started entrypoint: - php - -e @@ -159,6 +161,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_SMTP_HOST - _APP_SMTP_PORT - _APP_SMTP_SECURE @@ -246,7 +254,7 @@ services: appwrite-console: <<: *x-logging container_name: appwrite-console - image: appwrite/console:7.5.7 + image: appwrite/console:7.8.26 restart: unless-stopped networks: - appwrite @@ -295,6 +303,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -311,6 +320,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_USAGE_STATS - _APP_LOGGING_CONFIG - _APP_LOGGING_CONFIG_REALTIME @@ -330,6 +345,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -363,6 +379,7 @@ services: - ${_APP_DB_HOST:-mongodb} - request-catcher-sms - request-catcher-webhook + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -393,6 +410,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama volumes: - appwrite-uploads:/storage/uploads:rw - appwrite-cache:/storage/cache:rw @@ -402,6 +420,7 @@ services: - appwrite-certificates:/storage/certificates:rw - ./app:/usr/src/code/app - ./src:/usr/src/code/src + environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -458,9 +477,11 @@ services: volumes: - ./app:/usr/src/code/app - ./src:/usr/src/code/src + depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -476,6 +497,12 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER_VECTORSDB + - _APP_DB_HOST_VECTORSDB + - _APP_DB_PORT_VECTORSDB + - _APP_DB_SCHEMA_VECTORSDB + - _APP_DB_USER_VECTORSDB + - _APP_DB_PASS_VECTORSDB - _APP_LOGGING_CONFIG - _APP_WORKERS_NUM - _APP_QUEUE_NAME @@ -497,6 +524,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -629,6 +657,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama volumes: - appwrite-config:/storage/config:rw - appwrite-certificates:/storage/certificates:rw @@ -848,6 +877,7 @@ services: - ./app:/usr/src/code/app - ./src:/usr/src/code/src - ./tests:/usr/src/code/tests + depends_on: - ${_APP_DB_HOST:-mongodb} environment: @@ -1044,6 +1074,7 @@ services: depends_on: - redis - ${_APP_DB_HOST:-mongodb} + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1077,6 +1108,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1107,6 +1139,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1137,6 +1170,7 @@ services: depends_on: - ${_APP_DB_HOST:-mongodb} - redis + - ollama environment: - _APP_ENV - _APP_WORKER_PER_CORE @@ -1228,7 +1262,6 @@ services: start_period: 5s mariadb: - profiles: ["mariadb"] image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p container_name: appwrite-mariadb <<: *x-logging @@ -1252,7 +1285,6 @@ services: retries: 12 mongodb: - profiles: ["mongodb"] image: mongo:8.2.5 container_name: appwrite-mongodb <<: *x-logging @@ -1288,32 +1320,41 @@ services: retries: 10 start_period: 30s - - postgresql: - profiles: ["postgresql"] - build: - context: ./tests/resources/postgresql - args: - POSTGRES_VERSION: 17 + image: appwrite/postgres:0.1.0 container_name: appwrite-postgresql <<: *x-logging networks: - appwrite volumes: - - appwrite-postgresql:/var/lib/postgresql:rw + - appwrite-postgresql:/var/lib/postgresql/18/data:rw ports: - "5432:5432" environment: - POSTGRES_DB=${_APP_DB_SCHEMA} - POSTGRES_USER=${_APP_DB_USER} - POSTGRES_PASSWORD=${_APP_DB_PASS} - command: "postgres" healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER}"] + test: ["CMD-SHELL", "pg_isready -U ${_APP_DB_USER} -d ${_APP_DB_SCHEMA}"] interval: 5s timeout: 5s - retries: 12 + retries: 10 + start_period: 10s + command: "postgres" + + ollama: + image: appwrite/ollama:0.1.1 + container_name: ollama + ports: + - "11434:11434" + restart: unless-stopped + environment: + MODELS: ${_APP_EMBEDDING_MODELS:-embeddinggemma} + OLLAMA_KEEP_ALIVE: 24h + volumes: + - appwrite-models:/root/.ollama + networks: + - appwrite redis: image: redis:7.4.7-alpine @@ -1436,3 +1477,4 @@ volumes: appwrite-sites: appwrite-builds: appwrite-config: + appwrite-models: \ No newline at end of file diff --git a/docs/references/documentsdb/create-collection.md b/docs/references/documentsdb/create-collection.md new file mode 100644 index 0000000000..c6293a4c38 --- /dev/null +++ b/docs/references/documentsdb/create-collection.md @@ -0,0 +1 @@ +Create a new Collection. Before using this route, you should create a new database resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-document.md b/docs/references/documentsdb/create-document.md new file mode 100644 index 0000000000..197744b4a0 --- /dev/null +++ b/docs/references/documentsdb/create-document.md @@ -0,0 +1 @@ +Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-documents.md b/docs/references/documentsdb/create-documents.md new file mode 100644 index 0000000000..9f4a4a1396 --- /dev/null +++ b/docs/references/documentsdb/create-documents.md @@ -0,0 +1 @@ +Create new Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/create-index.md b/docs/references/documentsdb/create-index.md new file mode 100644 index 0000000000..164b754161 --- /dev/null +++ b/docs/references/documentsdb/create-index.md @@ -0,0 +1,2 @@ +Creates an index on the attributes listed. Your index should include all the attributes you will query in a single request. +Attributes can be `key`, `fulltext`, and `unique`. \ No newline at end of file diff --git a/docs/references/documentsdb/create-operations.md b/docs/references/documentsdb/create-operations.md new file mode 100644 index 0000000000..a737b95a55 --- /dev/null +++ b/docs/references/documentsdb/create-operations.md @@ -0,0 +1 @@ +Create multiple operations in a single transaction. \ No newline at end of file diff --git a/docs/references/documentsdb/create-transaction.md b/docs/references/documentsdb/create-transaction.md new file mode 100644 index 0000000000..fdf369a789 --- /dev/null +++ b/docs/references/documentsdb/create-transaction.md @@ -0,0 +1 @@ +Create a new transaction. \ No newline at end of file diff --git a/docs/references/documentsdb/create.md b/docs/references/documentsdb/create.md new file mode 100644 index 0000000000..b608485341 --- /dev/null +++ b/docs/references/documentsdb/create.md @@ -0,0 +1 @@ +Create a new Database. diff --git a/docs/references/documentsdb/decrement-document-attribute.md b/docs/references/documentsdb/decrement-document-attribute.md new file mode 100644 index 0000000000..b7b32d6148 --- /dev/null +++ b/docs/references/documentsdb/decrement-document-attribute.md @@ -0,0 +1 @@ +Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-collection.md b/docs/references/documentsdb/delete-collection.md new file mode 100644 index 0000000000..90f7aa6aa5 --- /dev/null +++ b/docs/references/documentsdb/delete-collection.md @@ -0,0 +1 @@ +Delete a collection by its unique ID. Only users with write permissions have access to delete this resource. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-document.md b/docs/references/documentsdb/delete-document.md new file mode 100644 index 0000000000..36fbf6802d --- /dev/null +++ b/docs/references/documentsdb/delete-document.md @@ -0,0 +1 @@ +Delete a document by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-documents.md b/docs/references/documentsdb/delete-documents.md new file mode 100644 index 0000000000..a7b05503de --- /dev/null +++ b/docs/references/documentsdb/delete-documents.md @@ -0,0 +1 @@ +Bulk delete documents using queries, if no queries are passed then all documents are deleted. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-index.md b/docs/references/documentsdb/delete-index.md new file mode 100644 index 0000000000..c5b8f49e5f --- /dev/null +++ b/docs/references/documentsdb/delete-index.md @@ -0,0 +1 @@ +Delete an index. \ No newline at end of file diff --git a/docs/references/documentsdb/delete-transaction.md b/docs/references/documentsdb/delete-transaction.md new file mode 100644 index 0000000000..f1395c228f --- /dev/null +++ b/docs/references/documentsdb/delete-transaction.md @@ -0,0 +1 @@ +Delete a transaction by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/delete.md b/docs/references/documentsdb/delete.md new file mode 100644 index 0000000000..605fa290d3 --- /dev/null +++ b/docs/references/documentsdb/delete.md @@ -0,0 +1 @@ +Delete a database by its unique ID. Only API keys with with databases.write scope can delete a database. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-logs.md b/docs/references/documentsdb/get-collection-logs.md new file mode 100644 index 0000000000..8578cef03c --- /dev/null +++ b/docs/references/documentsdb/get-collection-logs.md @@ -0,0 +1 @@ +Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection-usage.md b/docs/references/documentsdb/get-collection-usage.md new file mode 100644 index 0000000000..48682a075f --- /dev/null +++ b/docs/references/documentsdb/get-collection-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a collection. Returning the total number of documents. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/get-collection.md b/docs/references/documentsdb/get-collection.md new file mode 100644 index 0000000000..97b39e8474 --- /dev/null +++ b/docs/references/documentsdb/get-collection.md @@ -0,0 +1 @@ +Get a collection by its unique ID. This endpoint response returns a JSON object with the collection metadata. \ No newline at end of file diff --git a/docs/references/documentsdb/get-database-usage.md b/docs/references/documentsdb/get-database-usage.md new file mode 100644 index 0000000000..2c2628a464 --- /dev/null +++ b/docs/references/documentsdb/get-database-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a database. You can view the total number of collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document-logs.md b/docs/references/documentsdb/get-document-logs.md new file mode 100644 index 0000000000..9b96df5ad4 --- /dev/null +++ b/docs/references/documentsdb/get-document-logs.md @@ -0,0 +1 @@ +Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-document.md b/docs/references/documentsdb/get-document.md new file mode 100644 index 0000000000..4e4d76bec0 --- /dev/null +++ b/docs/references/documentsdb/get-document.md @@ -0,0 +1 @@ +Get a document by its unique ID. This endpoint response returns a JSON object with the document data. \ No newline at end of file diff --git a/docs/references/documentsdb/get-index.md b/docs/references/documentsdb/get-index.md new file mode 100644 index 0000000000..cdea5b4f27 --- /dev/null +++ b/docs/references/documentsdb/get-index.md @@ -0,0 +1 @@ +Get index by ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-logs.md b/docs/references/documentsdb/get-logs.md new file mode 100644 index 0000000000..8e49da4603 --- /dev/null +++ b/docs/references/documentsdb/get-logs.md @@ -0,0 +1 @@ +Get the database activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get-transaction.md b/docs/references/documentsdb/get-transaction.md new file mode 100644 index 0000000000..41900f7468 --- /dev/null +++ b/docs/references/documentsdb/get-transaction.md @@ -0,0 +1 @@ +Get a transaction by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/get.md b/docs/references/documentsdb/get.md new file mode 100644 index 0000000000..24183f6f6b --- /dev/null +++ b/docs/references/documentsdb/get.md @@ -0,0 +1 @@ +Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/documentsdb/increment-document-attribute.md b/docs/references/documentsdb/increment-document-attribute.md new file mode 100644 index 0000000000..7a19b3fbc7 --- /dev/null +++ b/docs/references/documentsdb/increment-document-attribute.md @@ -0,0 +1 @@ +Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/documentsdb/list-attributes.md b/docs/references/documentsdb/list-attributes.md new file mode 100644 index 0000000000..72ad6d727f --- /dev/null +++ b/docs/references/documentsdb/list-attributes.md @@ -0,0 +1 @@ +List attributes in the collection. \ No newline at end of file diff --git a/docs/references/documentsdb/list-collections.md b/docs/references/documentsdb/list-collections.md new file mode 100644 index 0000000000..e437674915 --- /dev/null +++ b/docs/references/documentsdb/list-collections.md @@ -0,0 +1 @@ +Get a list of all collections that belong to the provided databaseId. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/list-documents.md b/docs/references/documentsdb/list-documents.md new file mode 100644 index 0000000000..4e2ae91792 --- /dev/null +++ b/docs/references/documentsdb/list-documents.md @@ -0,0 +1 @@ +Get a list of all the user's documents in a given collection. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/list-indexes.md b/docs/references/documentsdb/list-indexes.md new file mode 100644 index 0000000000..a8c687fb2b --- /dev/null +++ b/docs/references/documentsdb/list-indexes.md @@ -0,0 +1 @@ +List indexes in the collection. \ No newline at end of file diff --git a/docs/references/documentsdb/list-transactions.md b/docs/references/documentsdb/list-transactions.md new file mode 100644 index 0000000000..9a63d9f04a --- /dev/null +++ b/docs/references/documentsdb/list-transactions.md @@ -0,0 +1 @@ +List transactions across all databases. \ No newline at end of file diff --git a/docs/references/documentsdb/list-usage.md b/docs/references/documentsdb/list-usage.md new file mode 100644 index 0000000000..a88e76680e --- /dev/null +++ b/docs/references/documentsdb/list-usage.md @@ -0,0 +1 @@ +List usage metrics and statistics for all databases in the project. You can view the total number of databases, collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/documentsdb/list.md b/docs/references/documentsdb/list.md new file mode 100644 index 0000000000..d93fb9d7a8 --- /dev/null +++ b/docs/references/documentsdb/list.md @@ -0,0 +1 @@ +Get a list of all databases from the current Appwrite project. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/documentsdb/update-collection.md b/docs/references/documentsdb/update-collection.md new file mode 100644 index 0000000000..b8f6bef997 --- /dev/null +++ b/docs/references/documentsdb/update-collection.md @@ -0,0 +1 @@ +Update a collection by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/update-document.md b/docs/references/documentsdb/update-document.md new file mode 100644 index 0000000000..526f3971d1 --- /dev/null +++ b/docs/references/documentsdb/update-document.md @@ -0,0 +1 @@ +Update a document by its unique ID. Using the patch method you can pass only specific fields that will get updated. \ No newline at end of file diff --git a/docs/references/documentsdb/update-documents.md b/docs/references/documentsdb/update-documents.md new file mode 100644 index 0000000000..5f560c6435 --- /dev/null +++ b/docs/references/documentsdb/update-documents.md @@ -0,0 +1 @@ +Update all documents that match your queries, if no queries are submitted then all documents are updated. You can pass only specific fields to be updated. \ No newline at end of file diff --git a/docs/references/documentsdb/update-transaction.md b/docs/references/documentsdb/update-transaction.md new file mode 100644 index 0000000000..d9d5f45439 --- /dev/null +++ b/docs/references/documentsdb/update-transaction.md @@ -0,0 +1 @@ +Update a transaction, to either commit or roll back its operations. \ No newline at end of file diff --git a/docs/references/documentsdb/update.md b/docs/references/documentsdb/update.md new file mode 100644 index 0000000000..4e99bf2e07 --- /dev/null +++ b/docs/references/documentsdb/update.md @@ -0,0 +1 @@ +Update a database by its unique ID. \ No newline at end of file diff --git a/docs/references/documentsdb/upsert-document.md b/docs/references/documentsdb/upsert-document.md new file mode 100644 index 0000000000..f1b68d13d5 --- /dev/null +++ b/docs/references/documentsdb/upsert-document.md @@ -0,0 +1 @@ +Create or update a Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/documentsdb/upsert-documents.md b/docs/references/documentsdb/upsert-documents.md new file mode 100644 index 0000000000..4feb473076 --- /dev/null +++ b/docs/references/documentsdb/upsert-documents.md @@ -0,0 +1 @@ +Create or update Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. diff --git a/docs/references/health/get-queue-audits.md b/docs/references/health/get-queue-audits.md index 75010cc2f4..bac075581f 100644 --- a/docs/references/health/get-queue-audits.md +++ b/docs/references/health/get-queue-audits.md @@ -1 +1 @@ -Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server. \ No newline at end of file +Get the number of audit logs that are waiting to be processed in the Appwrite internal queue server. diff --git a/docs/references/vectorsdb/create-collection.md b/docs/references/vectorsdb/create-collection.md new file mode 100644 index 0000000000..c6293a4c38 --- /dev/null +++ b/docs/references/vectorsdb/create-collection.md @@ -0,0 +1 @@ +Create a new Collection. Before using this route, you should create a new database resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/vectorsdb/create-document.md b/docs/references/vectorsdb/create-document.md new file mode 100644 index 0000000000..197744b4a0 --- /dev/null +++ b/docs/references/vectorsdb/create-document.md @@ -0,0 +1 @@ +Create a new Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/vectorsdb/create-documents.md b/docs/references/vectorsdb/create-documents.md new file mode 100644 index 0000000000..9f4a4a1396 --- /dev/null +++ b/docs/references/vectorsdb/create-documents.md @@ -0,0 +1 @@ +Create new Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/vectorsdb/create-index.md b/docs/references/vectorsdb/create-index.md new file mode 100644 index 0000000000..164b754161 --- /dev/null +++ b/docs/references/vectorsdb/create-index.md @@ -0,0 +1,2 @@ +Creates an index on the attributes listed. Your index should include all the attributes you will query in a single request. +Attributes can be `key`, `fulltext`, and `unique`. \ No newline at end of file diff --git a/docs/references/vectorsdb/create-operations.md b/docs/references/vectorsdb/create-operations.md new file mode 100644 index 0000000000..a737b95a55 --- /dev/null +++ b/docs/references/vectorsdb/create-operations.md @@ -0,0 +1 @@ +Create multiple operations in a single transaction. \ No newline at end of file diff --git a/docs/references/vectorsdb/create-transaction.md b/docs/references/vectorsdb/create-transaction.md new file mode 100644 index 0000000000..fdf369a789 --- /dev/null +++ b/docs/references/vectorsdb/create-transaction.md @@ -0,0 +1 @@ +Create a new transaction. \ No newline at end of file diff --git a/docs/references/vectorsdb/create.md b/docs/references/vectorsdb/create.md new file mode 100644 index 0000000000..b608485341 --- /dev/null +++ b/docs/references/vectorsdb/create.md @@ -0,0 +1 @@ +Create a new Database. diff --git a/docs/references/vectorsdb/decrement-document-attribute.md b/docs/references/vectorsdb/decrement-document-attribute.md new file mode 100644 index 0000000000..b7b32d6148 --- /dev/null +++ b/docs/references/vectorsdb/decrement-document-attribute.md @@ -0,0 +1 @@ +Decrement a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete-collection.md b/docs/references/vectorsdb/delete-collection.md new file mode 100644 index 0000000000..90f7aa6aa5 --- /dev/null +++ b/docs/references/vectorsdb/delete-collection.md @@ -0,0 +1 @@ +Delete a collection by its unique ID. Only users with write permissions have access to delete this resource. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete-document.md b/docs/references/vectorsdb/delete-document.md new file mode 100644 index 0000000000..36fbf6802d --- /dev/null +++ b/docs/references/vectorsdb/delete-document.md @@ -0,0 +1 @@ +Delete a document by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete-documents.md b/docs/references/vectorsdb/delete-documents.md new file mode 100644 index 0000000000..a7b05503de --- /dev/null +++ b/docs/references/vectorsdb/delete-documents.md @@ -0,0 +1 @@ +Bulk delete documents using queries, if no queries are passed then all documents are deleted. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete-index.md b/docs/references/vectorsdb/delete-index.md new file mode 100644 index 0000000000..c5b8f49e5f --- /dev/null +++ b/docs/references/vectorsdb/delete-index.md @@ -0,0 +1 @@ +Delete an index. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete-transaction.md b/docs/references/vectorsdb/delete-transaction.md new file mode 100644 index 0000000000..f1395c228f --- /dev/null +++ b/docs/references/vectorsdb/delete-transaction.md @@ -0,0 +1 @@ +Delete a transaction by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/delete.md b/docs/references/vectorsdb/delete.md new file mode 100644 index 0000000000..605fa290d3 --- /dev/null +++ b/docs/references/vectorsdb/delete.md @@ -0,0 +1 @@ +Delete a database by its unique ID. Only API keys with with databases.write scope can delete a database. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-logs.md b/docs/references/vectorsdb/get-collection-logs.md new file mode 100644 index 0000000000..8578cef03c --- /dev/null +++ b/docs/references/vectorsdb/get-collection-logs.md @@ -0,0 +1 @@ +Get the collection activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection-usage.md b/docs/references/vectorsdb/get-collection-usage.md new file mode 100644 index 0000000000..48682a075f --- /dev/null +++ b/docs/references/vectorsdb/get-collection-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a collection. Returning the total number of documents. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-collection.md b/docs/references/vectorsdb/get-collection.md new file mode 100644 index 0000000000..97b39e8474 --- /dev/null +++ b/docs/references/vectorsdb/get-collection.md @@ -0,0 +1 @@ +Get a collection by its unique ID. This endpoint response returns a JSON object with the collection metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-database-usage.md b/docs/references/vectorsdb/get-database-usage.md new file mode 100644 index 0000000000..2c2628a464 --- /dev/null +++ b/docs/references/vectorsdb/get-database-usage.md @@ -0,0 +1 @@ +Get usage metrics and statistics for a database. You can view the total number of collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document-logs.md b/docs/references/vectorsdb/get-document-logs.md new file mode 100644 index 0000000000..9b96df5ad4 --- /dev/null +++ b/docs/references/vectorsdb/get-document-logs.md @@ -0,0 +1 @@ +Get the document activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-document.md b/docs/references/vectorsdb/get-document.md new file mode 100644 index 0000000000..4e4d76bec0 --- /dev/null +++ b/docs/references/vectorsdb/get-document.md @@ -0,0 +1 @@ +Get a document by its unique ID. This endpoint response returns a JSON object with the document data. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-index.md b/docs/references/vectorsdb/get-index.md new file mode 100644 index 0000000000..cdea5b4f27 --- /dev/null +++ b/docs/references/vectorsdb/get-index.md @@ -0,0 +1 @@ +Get index by ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-logs.md b/docs/references/vectorsdb/get-logs.md new file mode 100644 index 0000000000..8e49da4603 --- /dev/null +++ b/docs/references/vectorsdb/get-logs.md @@ -0,0 +1 @@ +Get the database activity logs list by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get-transaction.md b/docs/references/vectorsdb/get-transaction.md new file mode 100644 index 0000000000..41900f7468 --- /dev/null +++ b/docs/references/vectorsdb/get-transaction.md @@ -0,0 +1 @@ +Get a transaction by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/get.md b/docs/references/vectorsdb/get.md new file mode 100644 index 0000000000..24183f6f6b --- /dev/null +++ b/docs/references/vectorsdb/get.md @@ -0,0 +1 @@ +Get a database by its unique ID. This endpoint response returns a JSON object with the database metadata. \ No newline at end of file diff --git a/docs/references/vectorsdb/increment-document-attribute.md b/docs/references/vectorsdb/increment-document-attribute.md new file mode 100644 index 0000000000..7a19b3fbc7 --- /dev/null +++ b/docs/references/vectorsdb/increment-document-attribute.md @@ -0,0 +1 @@ +Increment a specific column of a row by a given value. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-attributes.md b/docs/references/vectorsdb/list-attributes.md new file mode 100644 index 0000000000..72ad6d727f --- /dev/null +++ b/docs/references/vectorsdb/list-attributes.md @@ -0,0 +1 @@ +List attributes in the collection. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-collections.md b/docs/references/vectorsdb/list-collections.md new file mode 100644 index 0000000000..e437674915 --- /dev/null +++ b/docs/references/vectorsdb/list-collections.md @@ -0,0 +1 @@ +Get a list of all collections that belong to the provided databaseId. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-documents.md b/docs/references/vectorsdb/list-documents.md new file mode 100644 index 0000000000..4e2ae91792 --- /dev/null +++ b/docs/references/vectorsdb/list-documents.md @@ -0,0 +1 @@ +Get a list of all the user's documents in a given collection. You can use the query params to filter your results. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-indexes.md b/docs/references/vectorsdb/list-indexes.md new file mode 100644 index 0000000000..a8c687fb2b --- /dev/null +++ b/docs/references/vectorsdb/list-indexes.md @@ -0,0 +1 @@ +List indexes in the collection. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-transactions.md b/docs/references/vectorsdb/list-transactions.md new file mode 100644 index 0000000000..9a63d9f04a --- /dev/null +++ b/docs/references/vectorsdb/list-transactions.md @@ -0,0 +1 @@ +List transactions across all databases. \ No newline at end of file diff --git a/docs/references/vectorsdb/list-usage.md b/docs/references/vectorsdb/list-usage.md new file mode 100644 index 0000000000..a88e76680e --- /dev/null +++ b/docs/references/vectorsdb/list-usage.md @@ -0,0 +1 @@ +List usage metrics and statistics for all databases in the project. You can view the total number of databases, collections, documents, and storage usage. The response includes both current totals and historical data over time. Use the optional range parameter to specify the time window for historical data: 24h (last 24 hours), 30d (last 30 days), or 90d (last 90 days). If not specified, range defaults to 30 days. \ No newline at end of file diff --git a/docs/references/vectorsdb/list.md b/docs/references/vectorsdb/list.md new file mode 100644 index 0000000000..d93fb9d7a8 --- /dev/null +++ b/docs/references/vectorsdb/list.md @@ -0,0 +1 @@ +Get a list of all databases from the current Appwrite project. You can use the search parameter to filter your results. \ No newline at end of file diff --git a/docs/references/vectorsdb/update-collection.md b/docs/references/vectorsdb/update-collection.md new file mode 100644 index 0000000000..b8f6bef997 --- /dev/null +++ b/docs/references/vectorsdb/update-collection.md @@ -0,0 +1 @@ +Update a collection by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/update-document.md b/docs/references/vectorsdb/update-document.md new file mode 100644 index 0000000000..526f3971d1 --- /dev/null +++ b/docs/references/vectorsdb/update-document.md @@ -0,0 +1 @@ +Update a document by its unique ID. Using the patch method you can pass only specific fields that will get updated. \ No newline at end of file diff --git a/docs/references/vectorsdb/update-documents.md b/docs/references/vectorsdb/update-documents.md new file mode 100644 index 0000000000..5f560c6435 --- /dev/null +++ b/docs/references/vectorsdb/update-documents.md @@ -0,0 +1 @@ +Update all documents that match your queries, if no queries are submitted then all documents are updated. You can pass only specific fields to be updated. \ No newline at end of file diff --git a/docs/references/vectorsdb/update-transaction.md b/docs/references/vectorsdb/update-transaction.md new file mode 100644 index 0000000000..d9d5f45439 --- /dev/null +++ b/docs/references/vectorsdb/update-transaction.md @@ -0,0 +1 @@ +Update a transaction, to either commit or roll back its operations. \ No newline at end of file diff --git a/docs/references/vectorsdb/update.md b/docs/references/vectorsdb/update.md new file mode 100644 index 0000000000..4e99bf2e07 --- /dev/null +++ b/docs/references/vectorsdb/update.md @@ -0,0 +1 @@ +Update a database by its unique ID. \ No newline at end of file diff --git a/docs/references/vectorsdb/upsert-document.md b/docs/references/vectorsdb/upsert-document.md new file mode 100644 index 0000000000..f1b68d13d5 --- /dev/null +++ b/docs/references/vectorsdb/upsert-document.md @@ -0,0 +1 @@ +Create or update a Document. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. \ No newline at end of file diff --git a/docs/references/vectorsdb/upsert-documents.md b/docs/references/vectorsdb/upsert-documents.md new file mode 100644 index 0000000000..4feb473076 --- /dev/null +++ b/docs/references/vectorsdb/upsert-documents.md @@ -0,0 +1 @@ +Create or update Documents. Before using this route, you should create a new collection resource using either a [server integration](https://appwrite.io/docs/server/databases#documentsDBCreateCollection) API or directly from your database console. diff --git a/docs/sdks/markdown/CHANGELOG.md b/docs/sdks/markdown/CHANGELOG.md deleted file mode 100644 index 26fcb5bbce..0000000000 --- a/docs/sdks/markdown/CHANGELOG.md +++ /dev/null @@ -1,17 +0,0 @@ -# Change Log - -## 0.3.0 - -* Add `bytesMax` and `bytesUsed` properties to Collection and Table documentation -* Add `queries` parameter to `listKeys` and `keyId` parameter to `createKey` documentation -* Add `dart-3.10` and `flutter-3.38` runtimes -* Fix Teams membership docs to use `string[]` instead of `Roles[]` - -## 0.2.0 - -* Document array-based enum parameters in Markdown examples (e.g., `permissions: BrowserPermission[]`). -* Breaking change: `Output` enum has been removed; use `ImageFormat` instead. - -## 0.1.0 - -* Initial release diff --git a/docs/sdks/rust/CHANGELOG.md b/docs/sdks/rust/CHANGELOG.md new file mode 100644 index 0000000000..bbfc68354e --- /dev/null +++ b/docs/sdks/rust/CHANGELOG.md @@ -0,0 +1,5 @@ +# Change Log + +## 0.1.0 + +* Initial release diff --git a/docs/sdks/rust/GETTING_STARTED.md b/docs/sdks/rust/GETTING_STARTED.md new file mode 100644 index 0000000000..123315b442 --- /dev/null +++ b/docs/sdks/rust/GETTING_STARTED.md @@ -0,0 +1,68 @@ +## Getting Started + +### Init your SDK +Initialize your SDK with your Appwrite server API endpoint and project ID which can be found on your project settings page and your new API secret Key from project's API keys section. + +```rust +use appwrite::client::Client; + +let client = Client::new() + .set_endpoint("https://[HOSTNAME_OR_IP]/v1") // Your API Endpoint + .set_project("5df5acd0d48c2") // Your project ID + .set_key("919c2d18fb5d4...a2ae413da83346ad2") // Your secret API key + .set_self_signed(true); // Use only on dev mode with a self-signed SSL cert +``` + +### Make Your First Request +Once your SDK object is set, create any of the Appwrite service objects and choose any request to send. Full documentation for any service method you would like to use can be found in your SDK documentation or in the [API References](https://appwrite.io/docs) section. + +```rust +use appwrite::client::Client; +use appwrite::services::users::Users; +use appwrite::id::ID; + +let client = Client::new() + .set_endpoint("https://[HOSTNAME_OR_IP]/v1") + .set_project("5df5acd0d48c2") + .set_key("919c2d18fb5d4...a2ae413da83346ad2") + .set_self_signed(true); + +let users = Users::new(&client); + +let user = users.create( + ID::unique(), + Some("email@example.com"), + Some("+123456789"), + Some("password"), + Some("Walter O'Brien"), +).await?; + +println!("{}", user.name); +println!("{}", user.email); +``` + +### Error Handling +The Appwrite Rust SDK returns `Result` types. You can handle errors using standard Rust error handling patterns. Below is an example. + +```rust +use appwrite::error::AppwriteError; + +match users.create( + ID::unique(), + Some("email@example.com"), + Some("+123456789"), + Some("password"), + Some("Walter O'Brien"), +).await { + Ok(user) => println!("{}", user.name), + Err(AppwriteError { message, code, .. }) => { + eprintln!("Error {}: {}", code, message); + } +} +``` + +### Learn more +You can use the following resources to learn more and get help +- 🚀 [Getting Started Tutorial](https://appwrite.io/docs/getting-started-for-server) +- 📜 [Appwrite Docs](https://appwrite.io/docs) +- 💬 [Discord Community](https://appwrite.io/discord) diff --git a/mongo-init-replicaset.sh b/mongo-init-replicaset.sh old mode 100755 new mode 100644 diff --git a/mongo-init.js b/mongo-init.js index edff6cc499..bc06ba5b23 100644 --- a/mongo-init.js +++ b/mongo-init.js @@ -15,4 +15,4 @@ adminDb.createUser({ roles: [ { role: 'readWrite', db: database } ] -}); +}); \ No newline at end of file diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 979deae17e..5da64a1c97 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -114,12 +114,6 @@ parameters: count: 1 path: app/controllers/mock.php - - - message: '#^Call to an undefined method Utopia\\Database\\Document\:\:getRoles\(\)\.$#' - identifier: method.notFound - count: 1 - path: app/controllers/shared/api.php - - message: '#^Variable \$register might not be defined\.$#' identifier: variable.undefined @@ -192,114 +186,6 @@ parameters: count: 3 path: app/worker.php - - - message: '#^PHPDoc tag @return with type string is incompatible with native type int\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Auth/OAuth2.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$token$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Auth/OAuth2/Disqus.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$value$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Auth/Validator/PersonalData.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Databases/TransactionState.php - - - - message: '#^PHPDoc tag @param has invalid value \(DeviceDetector\)\: Unexpected token "\\n ", expected variable at offset 32 on line 2$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Detector/Detector.php - - - - message: '#^PHPDoc tag @param has invalid value \(string\)\: Unexpected token "\\n ", expected variable at offset 24 on line 2$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Detector/Detector.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Compose.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Compose/Service.php - - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 1 - path: src/Appwrite/Docker/Env.php - - - - message: '#^PHPDoc tag @param has invalid value \(int port\)\: Unexpected token "port", expected variable at offset 50 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$password$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Appwrite\\Event\\Mail\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Event/Mail.php - - - - message: '#^Method Appwrite\\Event\\Message\\Usage\:\:fromArray\(\) should return static\(Appwrite\\Event\\Message\\Usage\) but returns Appwrite\\Event\\Message\\Usage\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Event/Message/Usage.php - - - - message: '#^PHPDoc tag @param references unknown parameter\: \$message$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Event/Messaging.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Event/Messaging.php - - - - message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getFunctionsEvents\(\) should return array\ but returns array\\>\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - - - message: '#^Method Appwrite\\Functions\\EventProcessor\:\:getWebhooksEvents\(\) should return array\ but returns array\\>\.$#' - identifier: return.type - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - - - message: '#^Variable \$hostname on left side of \?\? always exists and is not nullable\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Functions/EventProcessor.php - - message: '#^Anonymous function has an unused use \$context\.$#' identifier: closure.unusedUse @@ -372,30 +258,6 @@ parameters: count: 1 path: src/Appwrite/GraphQL/Types/Mapper.php - - - message: '#^Unsafe access to private property Appwrite\\GraphQL\\Types\\Mapper\:\:\$models through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getColumnImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 2 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getHashOptionsImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - - - message: '#^Unsafe call to private method Appwrite\\GraphQL\\Types\\Mapper\:\:getUnionImplementation\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/GraphQL/Types/Mapper.php - - message: '#^Call to an undefined method Appwrite\\Migration\\Version\\V15\:\:documentsIterator\(\)\.$#' identifier: method.notFound @@ -462,12 +324,6 @@ parameters: count: 1 path: src/Appwrite/OpenSSL/OpenSSL.php - - - message: '#^PHPDoc tag @param references unknown parameter\: \$projectId$#' - identifier: parameter.notFound - count: 1 - path: src/Appwrite/Platform/Action.php - - message: '#^Variable \$output in empty\(\) always exists and is not falsy\.$#' identifier: empty.variable @@ -600,12 +456,6 @@ parameters: count: 1 path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - - message: '#^Variable \$currentDocumentId on left side of \?\? always exists and is always null\.$#' - identifier: nullCoalesce.variable - count: 1 - path: src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php - - message: '#^Offset ''deviceBrand'' does not exist on int\.$#' identifier: offsetAccess.notFound @@ -1170,12 +1020,6 @@ parameters: count: 1 path: src/Appwrite/SDK/Specification/Format/Swagger2.php - - - message: '#^PHPDoc tag @var above a method has no effect\.$#' - identifier: varTag.misplaced - count: 2 - path: src/Appwrite/Template/Template.php - - message: '#^PHPDoc tag @param has invalid value \(Document \$this\)\: Unexpected token "\$this", expected variable at offset 69 on line 4$#' identifier: phpDoc.parseError @@ -1188,84 +1032,11 @@ parameters: count: 1 path: src/Appwrite/Utopia/Database/Documents/User.php - - - message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:appendSymbol\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 4 - path: src/Appwrite/Utopia/Request/Filters/V17.php - - - - message: '#^Unsafe call to private method Appwrite\\Utopia\\Request\\Filters\\V17\:\:isSpecialChar\(\) through static\:\:\.$#' - identifier: staticClassAccess.privateMethod - count: 1 - path: src/Appwrite/Utopia/Request/Filters/V17.php - - - - message: '#^PHPDoc tag @param has invalid value \(callable The callback to show sensitive information for\)\: Unexpected token "The", expected variable at offset 91 on line 4$#' - identifier: phpDoc.parseError - count: 1 - path: src/Appwrite/Utopia/Response.php - - - - message: '#^PHPDoc tag @return with type Appwrite\\Utopia\\Response\\Filter is incompatible with native type array\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Utopia/Response.php - - - - message: '#^PHPDoc tag @return with type string is incompatible with native type Utopia\\Database\\Document\.$#' - identifier: return.phpDocType - count: 1 - path: src/Appwrite/Utopia/Response/Model/User.php - - - - message: '#^Attribute class Tests\\E2E\\General\\Retry does not exist\.$#' - identifier: attribute.notFound - count: 1 - path: tests/e2e/General/UsageTest.php - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Databases\\Legacy\\DatabasesStringTypesTest\:\:\$setupCache through static\:\:\.$#' identifier: staticClassAccess.privateProperty count: 4 path: tests/e2e/Services/Databases/Legacy/DatabasesStringTypesTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/Databases/LegacyConsoleClientTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/Databases/LegacyCustomClientTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/Databases/LegacyCustomClientTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/Databases/LegacyCustomServerTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/Databases/LegacyCustomServerTest.php - - message: '#^Call to an undefined method Tests\\E2E\\Services\\Databases\\Permissions\\LegacyPermissionsGuestTest\:\:getIndexUrl\(\)\.$#' identifier: method.notFound @@ -1296,108 +1067,12 @@ parameters: count: 8 path: tests/e2e/Services/Functions/FunctionsCustomServerTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedDeployment through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedExecution through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsClientTest\:\:\$cachedFunction through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/FunctionsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedDeployment through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedExecution through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\FunctionsServerTest\:\:\$cachedFunction through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/FunctionsServerTest.php - - message: '#^Binary operation "\+" between string and 1 results in an error\.$#' identifier: binaryOp.invalid count: 1 path: tests/e2e/Services/GraphQL/Legacy/AbuseTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$bulkData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$collection through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$database through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\Legacy\\DatabaseClientTest\:\:\$document through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedEmail through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 2 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedProviders through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 8 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedPush through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSms through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedSubscriber through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\MessagingTest\:\:\$cachedTopic through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 9 - path: tests/e2e/Services/GraphQL/MessagingTest.php - - message: '#^Variable \$from in empty\(\) is never defined\.$#' identifier: empty.variable @@ -1410,174 +1085,18 @@ parameters: count: 1 path: tests/e2e/Services/GraphQL/StorageClientTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedBucket through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/StorageClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageClientTest\:\:\$cachedFile through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/StorageClientTest.php - - message: '#^Method Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:testGetFileDownload\(\) should return array but return statement is missing\.$#' identifier: return.missing count: 1 path: tests/e2e/Services/GraphQL/StorageServerTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedBucket through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/StorageServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\StorageServerTest\:\:\$cachedFile through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 6 - path: tests/e2e/Services/GraphQL/StorageServerTest.php - - message: '#^Binary operation "\+" between string and 1 results in an error\.$#' identifier: binaryOp.invalid count: 1 path: tests/e2e/Services/GraphQL/TablesDB/AbuseTest.php - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBooleanColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedBulkData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatabase through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedDatetimeColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEmailColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedEnumColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedFloatColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIPColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIndexData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 7 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedIntegerColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRelationshipColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedRowData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedStringColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedTableData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TablesDB\\DatabaseServerTest\:\:\$cachedURLColumnData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedMembership through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TeamsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsClientTest\:\:\$cachedTeam through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedMembership through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeam through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\TeamsServerTest\:\:\$cachedTeamWithPrefs through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/TeamsServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUser through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 3 - path: tests/e2e/Services/GraphQL/UsersTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\GraphQL\\UsersTest\:\:\$cachedUserTarget through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 5 - path: tests/e2e/Services/GraphQL/UsersTest.php - - message: '#^Variable \$from in empty\(\) is never defined\.$#' identifier: empty.variable @@ -1599,13 +1118,13 @@ parameters: - message: '#^Anonymous function has an unused use \$databaseId\.$#' identifier: closure.unusedUse - count: 5 + count: 6 path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - message: '#^Anonymous function has an unused use \$tableId\.$#' identifier: closure.unusedUse - count: 5 + count: 6 path: tests/e2e/Services/Migrations/MigrationsConsoleClientTest.php - @@ -1649,73 +1168,6 @@ parameters: identifier: method.notFound count: 1 path: tests/e2e/Services/TablesDB/Permissions/TablesDBPermissionsTeamTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/TablesDB/TablesDBConsoleClientTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/TablesDB/TablesDBCustomClientTest.php - - - - message: '#^Variable \$library might not be defined\.$#' - identifier: variable.undefined - count: 1 - path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php - - - - message: '#^Variable \$person might not be defined\.$#' - identifier: variable.undefined - count: 4 - path: tests/e2e/Services/TablesDB/TablesDBCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensConsoleClientTest\:\:\$tokenData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensConsoleClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomClientTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomClientTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$bucketAndFileData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomServerTest.php - - - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Tokens\\TokensCustomServerTest\:\:\$tokenData through static\:\:\.$#' - identifier: staticClassAccess.privateProperty - count: 4 - path: tests/e2e/Services/Tokens/TokensCustomServerTest.php - - message: '#^Unsafe access to private property Tests\\E2E\\Services\\Users\\UsersCustomServerTest\:\:\$cachedHashedPasswordUsers through static\:\:\.$#' identifier: staticClassAccess.privateProperty @@ -1775,75 +1227,3 @@ parameters: identifier: method.notFound count: 1 path: tests/unit/Event/EventTest.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 6 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V16\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V16Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V16Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 5 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V17\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V17Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V17Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 4 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V18\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V18Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V18Test.php - - - - message: '#^Call to method parse\(\) on an unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter\.$#' - identifier: class.notFound - count: 11 - path: tests/unit/Utopia/Response/Filters/V19Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter \(Tests\\Unit\\Utopia\\Response\\Filters\\Filter\) does not accept Appwrite\\Utopia\\Response\\Filters\\V19\.$#' - identifier: assign.propertyType - count: 1 - path: tests/unit/Utopia/Response/Filters/V19Test.php - - - - message: '#^Property Tests\\Unit\\Utopia\\Response\\Filters\\V19Test\:\:\$filter has unknown class Tests\\Unit\\Utopia\\Response\\Filters\\Filter as its type\.$#' - identifier: class.notFound - count: 1 - path: tests/unit/Utopia/Response/Filters/V19Test.php diff --git a/phpunit.xml b/phpunit.xml index 9ccbaf47cc..9748c5a5c8 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -37,6 +37,7 @@ ./tests/e2e/Services/ProjectWebhooks ./tests/e2e/Services/Messaging ./tests/e2e/Services/Migrations + ./tests/e2e/Services/Project ./tests/e2e/Services/Functions/FunctionsBase.php ./tests/e2e/Services/Functions/FunctionsCustomServerTest.php ./tests/e2e/Services/Functions/FunctionsCustomClientTest.php diff --git a/src/Appwrite/Auth/OAuth2.php b/src/Appwrite/Auth/OAuth2.php index 9358c89547..a8a2d175b5 100644 --- a/src/Appwrite/Auth/OAuth2.php +++ b/src/Appwrite/Auth/OAuth2.php @@ -155,7 +155,7 @@ abstract class OAuth2 /** * @param string $code * - * @return string + * @return int */ public function getAccessTokenExpiry(string $code): int { diff --git a/src/Appwrite/Auth/OAuth2/Disqus.php b/src/Appwrite/Auth/OAuth2/Disqus.php index 58b7f48914..738c6d503e 100644 --- a/src/Appwrite/Auth/OAuth2/Disqus.php +++ b/src/Appwrite/Auth/OAuth2/Disqus.php @@ -108,7 +108,7 @@ class Disqus extends OAuth2 } /** - * @param string $token + * @param string $accessToken * * @return string */ diff --git a/src/Appwrite/Auth/Validator/PersonalData.php b/src/Appwrite/Auth/Validator/PersonalData.php index 8eaae002f6..3b09839bd1 100644 --- a/src/Appwrite/Auth/Validator/PersonalData.php +++ b/src/Appwrite/Auth/Validator/PersonalData.php @@ -33,7 +33,7 @@ class PersonalData extends Password /** * Is valid. * - * @param mixed $value + * @param mixed $password * * @return bool */ diff --git a/src/Appwrite/Databases/TransactionState.php b/src/Appwrite/Databases/TransactionState.php index 8e098774e6..71bd8799c7 100644 --- a/src/Appwrite/Databases/TransactionState.php +++ b/src/Appwrite/Databases/TransactionState.php @@ -21,17 +21,23 @@ class TransactionState { private Database $dbForProject; private Authorization $authorization; - /** @var Authorization $authorization */ - public function __construct(Database $dbForProject, Authorization $authorization) + /** + * @var callable(Document $database): Database + */ + private mixed $getDatabasesDB; + + public function __construct(Database $dbForProject, Authorization $authorization, callable $getDatabasesDB) { $this->dbForProject = $dbForProject; $this->authorization = $authorization; + $this->getDatabasesDB = $getDatabasesDB; } /** * Get a document with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string $documentId Document ID * @param string|null $transactionId Optional transaction ID @@ -42,13 +48,15 @@ class TransactionState * @throws Timeout */ public function getDocument( + Document $database, string $collectionId, string $documentId, ?string $transactionId = null, array $queries = [] ): Document { + $dbForDatabases = ($this->getDatabasesDB)($database); if ($transactionId === null) { - return $this->dbForProject->getDocument($collectionId, $documentId, $queries); + return $dbForDatabases->getDocument($collectionId, $documentId, $queries); } $state = $this->getTransactionState($transactionId); @@ -66,7 +74,7 @@ class TransactionState if ($docState['action'] === 'update' || $docState['action'] === 'upsert') { // Merge with committed version - $committedDoc = $this->dbForProject->getDocument($collectionId, $documentId, $queries); + $committedDoc = $dbForDatabases->getDocument($collectionId, $documentId, $queries); if (!$committedDoc->isEmpty()) { foreach ($docState['document']->getAttributes() as $key => $value) { if ($key !== '$id') { @@ -80,13 +88,13 @@ class TransactionState } } } - - return $this->dbForProject->getDocument($collectionId, $documentId, $queries); + return $dbForDatabases->getDocument($collectionId, $documentId, $queries); } /** * List documents with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string|null $transactionId Optional transaction ID * @param array $queries Optional query filters @@ -96,17 +104,19 @@ class TransactionState * @throws Timeout */ public function listDocuments( + Document $database, string $collectionId, ?string $transactionId = null, array $queries = [] ): array { + $dbForDatabases = ($this->getDatabasesDB)($database); // If no transaction, use normal database retrieval if ($transactionId === null) { - return $this->dbForProject->find($collectionId, $queries); + return $dbForDatabases->find($collectionId, $queries); } $state = $this->getTransactionState($transactionId); - $committedDocs = $this->dbForProject->find($collectionId, $queries); + $committedDocs = $dbForDatabases->find($collectionId, $queries); $documentMap = []; // Build map of committed documents @@ -147,6 +157,7 @@ class TransactionState /** * Count documents with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string|null $transactionId Optional transaction ID * @param array $queries Optional query filters @@ -156,23 +167,23 @@ class TransactionState * @throws Timeout */ public function countDocuments( + Document $database, string $collectionId, ?string $transactionId = null, array $queries = [] ): int { + $dbForDatabases = ($this->getDatabasesDB)($database); if ($transactionId === null) { - return $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + return $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT); } $state = $this->getTransactionState($transactionId); - - $baseCount = $this->dbForProject->count($collectionId, $queries, APP_LIMIT_COUNT); + $baseCount = $dbForDatabases->count($collectionId, $queries, APP_LIMIT_COUNT); if (!isset($state[$collectionId])) { return $baseCount; } - - $committedDocs = $this->dbForProject->find($collectionId, $queries); + $committedDocs = $dbForDatabases->find($collectionId, $queries); $committedDocIds = []; foreach ($committedDocs as $doc) { $committedDocIds[$doc->getId()] = true; @@ -214,17 +225,19 @@ class TransactionState /** * Check if a document exists with transaction-aware logic * + * @param Document $database Target database document * @param string $collectionId Collection ID * @param string $documentId Document ID * @param string|null $transactionId Optional transaction ID * @return bool True if document exists */ public function documentExists( + Document $database, string $collectionId, string $documentId, ?string $transactionId = null ): bool { - $doc = $this->getDocument($collectionId, $documentId, $transactionId); + $doc = $this->getDocument($database, $collectionId, $documentId, $transactionId); return !$doc->isEmpty(); } diff --git a/src/Appwrite/Detector/Detector.php b/src/Appwrite/Detector/Detector.php index 61286835f5..73259673dd 100644 --- a/src/Appwrite/Detector/Detector.php +++ b/src/Appwrite/Detector/Detector.php @@ -6,14 +6,8 @@ use DeviceDetector\DeviceDetector; class Detector { - /** - * @param string - */ protected $userAgent = ''; - /** - * @param DeviceDetector - */ protected $detctor; /** diff --git a/src/Appwrite/Docker/Compose.php b/src/Appwrite/Docker/Compose.php index 241e281ed8..9ea6420d2d 100644 --- a/src/Appwrite/Docker/Compose.php +++ b/src/Appwrite/Docker/Compose.php @@ -12,9 +12,6 @@ class Compose */ protected $compose = []; - /** - * @var string $data - */ public function __construct(string $data) { $this->compose = yaml_parse($data); diff --git a/src/Appwrite/Docker/Compose/Service.php b/src/Appwrite/Docker/Compose/Service.php index a3f9c91253..87699aaeba 100644 --- a/src/Appwrite/Docker/Compose/Service.php +++ b/src/Appwrite/Docker/Compose/Service.php @@ -11,9 +11,6 @@ class Service */ protected $service = []; - /** - * @var string $path - */ public function __construct(array $service) { $this->service = $service; diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 3bf6fb2d50..af5e4f11e2 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -9,9 +9,6 @@ class Env */ protected $vars = []; - /** - * @var string $data - */ public function __construct(string $data) { $data = explode("\n", $data); diff --git a/src/Appwrite/Event/Event.php b/src/Appwrite/Event/Event.php index ba633b4478..bf6339f8a0 100644 --- a/src/Appwrite/Event/Event.php +++ b/src/Appwrite/Event/Event.php @@ -519,6 +519,7 @@ class Event * @param string $pattern * @param array $params * @param ?Document $database + * @param ?Document $database * @return array * @throws \InvalidArgumentException */ @@ -533,7 +534,7 @@ class Event $parsed = self::parseEventPattern($pattern); // to switch the resource types from databases to the required prefix // eg; all databases events get fired with databases. prefix which mainly depicts legacy type - // so a projection from databases to the actual prefix + // so a projection from databases to the actual prefix(documentsdb, vectorsdb,etc) if ((str_contains($pattern, 'databases.') && $database && $database->getAttribute('type') !== 'legacy')) { $parsed = self::getDatabaseTypeEvents($database, $parsed); } @@ -695,7 +696,6 @@ class Event ) ) { $pairedEvents = []; - foreach ($events as $event) { $pairedEvents[] = $event; // tablesdb needs databases event with tables and collections @@ -745,6 +745,13 @@ class Event 'attributes' => 'columns', ]; break; + case 'documentsdb': + case 'vectorsdb': + // sending the type itself(eg: documentsdb, vectorsdb) + $eventMap = [ + 'databases' => $database->getAttribute('type') + ]; + break; } foreach ($event as $eventKey => $eventValue) { if (isset($eventMap[$eventValue])) { diff --git a/src/Appwrite/Event/Mail.php b/src/Appwrite/Event/Mail.php index 2d12aa542c..38d7a27c11 100644 --- a/src/Appwrite/Event/Mail.php +++ b/src/Appwrite/Event/Mail.php @@ -101,7 +101,8 @@ class Mail extends Event /** * Sets preview for the mail event. * - * @return string + * @param string $preview + * @return self */ public function setPreview(string $preview): self { @@ -115,7 +116,7 @@ class Mail extends Event * * @return string */ - public function getPreview(string $preview): string + public function getPreview(): string { return $this->preview; } @@ -181,7 +182,7 @@ class Mail extends Event /** * Set SMTP port * - * @param int port + * @param int $port * @return self */ public function setSmtpPort(int $port): self @@ -217,7 +218,7 @@ class Mail extends Event /** * Set SMTP secure * - * @param string $password + * @param string $secure * @return self */ public function setSmtpSecure(string $secure): self diff --git a/src/Appwrite/Event/Message/Usage.php b/src/Appwrite/Event/Message/Usage.php index 776188d5b5..c97b96a855 100644 --- a/src/Appwrite/Event/Message/Usage.php +++ b/src/Appwrite/Event/Message/Usage.php @@ -40,7 +40,7 @@ class Usage extends Base */ public static function fromArray(array $data): static { - return new self( + return new static( project: new Document($data['project'] ?? []), metrics: $data['metrics'] ?? [], reduce: array_map(fn (array $doc) => new Document($doc), $data['reduce'] ?? []), diff --git a/src/Appwrite/Event/Messaging.php b/src/Appwrite/Event/Messaging.php index 8c13185e0b..9895d52ec2 100644 --- a/src/Appwrite/Event/Messaging.php +++ b/src/Appwrite/Event/Messaging.php @@ -86,7 +86,7 @@ class Messaging extends Event /** * Returns message document for the messaging event. * - * @return string + * @return Document */ public function getMessage(): Document { @@ -96,7 +96,7 @@ class Messaging extends Event /** * Sets message ID for the messaging event. * - * @param string $message + * @param string $messageId * @return self */ public function setMessageId(string $messageId): self diff --git a/src/Appwrite/Event/Realtime.php b/src/Appwrite/Event/Realtime.php index 419863191e..747fd786f9 100644 --- a/src/Appwrite/Event/Realtime.php +++ b/src/Appwrite/Event/Realtime.php @@ -4,6 +4,7 @@ namespace Appwrite\Event; use Appwrite\Messaging\Adapter; use Appwrite\Messaging\Adapter\Realtime as RealtimeAdapter; +use Utopia\Console; use Utopia\Database\Document; use Utopia\Database\Exception; @@ -96,17 +97,21 @@ class Realtime extends Event : [$target['projectId'] ?? $this->getProject()->getId()]; foreach ($projectIds as $projectId) { - $this->realtime->send( - projectId: $projectId, - payload: $this->getRealtimePayload(), - events: $allEvents, - channels: $target['channels'], - roles: $target['roles'], - options: [ - 'permissionsChanged' => $target['permissionsChanged'], - 'userId' => $this->getParam('userId') - ] - ); + try { + $this->realtime->send( + projectId: $projectId, + payload: $this->getRealtimePayload(), + events: $allEvents, + channels: $target['channels'], + roles: $target['roles'], + options: [ + 'permissionsChanged' => $target['permissionsChanged'], + 'userId' => $this->getParam('userId') + ] + ); + } catch (\Exception $e) { + Console::error('Realtime send failed: '.$e->getMessage()); + } } return true; diff --git a/src/Appwrite/Extend/Exception.php b/src/Appwrite/Extend/Exception.php index a54edf7074..f7c76d3800 100644 --- a/src/Appwrite/Extend/Exception.php +++ b/src/Appwrite/Extend/Exception.php @@ -340,6 +340,7 @@ class Exception extends \Exception public const string MIGRATION_ALREADY_EXISTS = 'migration_already_exists'; public const string MIGRATION_IN_PROGRESS = 'migration_in_progress'; public const string MIGRATION_PROVIDER_ERROR = 'migration_provider_error'; + public const string MIGRATION_DATABASE_TYPE_UNSUPPORTED = 'migration_database_type_unsupported'; /** Realtime */ public const string REALTIME_MESSAGE_FORMAT_INVALID = 'realtime_message_format_invalid'; diff --git a/src/Appwrite/Functions/EventProcessor.php b/src/Appwrite/Functions/EventProcessor.php index e9c3b7241a..d41ee56c5d 100644 --- a/src/Appwrite/Functions/EventProcessor.php +++ b/src/Appwrite/Functions/EventProcessor.php @@ -8,6 +8,18 @@ use Utopia\Database\Query; class EventProcessor { + /** + * @param array $events + * @return array + */ + private function getEventMap(array $events): array + { + return \array_fill_keys( + \array_map('strval', \array_unique($events)), + true + ); + } + /** * Get function events for a project, using Redis cache * @param Document|null $project @@ -26,7 +38,7 @@ class EventProcessor $cacheKey = \sprintf( '%s-cache-%s:%s:%s:project:%s:functions:events', $dbForProject->getCacheName(), - $hostname ?? '', + $hostname, $dbForProject->getNamespace(), $dbForProject->getTenant(), $project->getId() @@ -36,7 +48,9 @@ class EventProcessor $cachedFunctionEvents = $dbForProject->getCache()->load($cacheKey, $ttl); if ($cachedFunctionEvents !== false) { - return \json_decode($cachedFunctionEvents, true) ?? []; + $decoded = \json_decode($cachedFunctionEvents, true); + + return \is_array($decoded) ? $this->getEventMap(\array_keys($decoded)) : []; } $events = []; @@ -63,7 +77,7 @@ class EventProcessor } } - $uniqueEvents = \array_flip(\array_unique($events)); + $uniqueEvents = $this->getEventMap($events); $dbForProject->getCache()->save($cacheKey, \json_encode($uniqueEvents)); return $uniqueEvents; @@ -97,6 +111,6 @@ class EventProcessor } } - return \array_flip(\array_unique($events)); + return $this->getEventMap($events); } } diff --git a/src/Appwrite/GraphQL/Types/Mapper.php b/src/Appwrite/GraphQL/Types/Mapper.php index 037f80bcf7..de4913cec4 100644 --- a/src/Appwrite/GraphQL/Types/Mapper.php +++ b/src/Appwrite/GraphQL/Types/Mapper.php @@ -101,16 +101,16 @@ class Mapper if (\is_array($modelName)) { foreach ($modelName as $name) { - $models[] = static::$models[$name]; + $models[] = self::$models[$name]; } } else { - $models[] = static::$models[$modelName]; + $models[] = self::$models[$modelName]; } } } else { // If single response, get its model and wrap in array $modelName = $responses->getModel(); - $models = [static::$models[$modelName]]; + $models = [self::$models[$modelName]]; } foreach ($models as $model) { @@ -425,7 +425,7 @@ class Mapper 'name' => $unionName, 'types' => $types, 'resolveType' => static function ($object) use ($unionName) { - return static::getUnionImplementation($unionName, $object); + return self::getUnionImplementation($unionName, $object); }, ]); @@ -440,11 +440,11 @@ class Mapper switch ($name) { case 'Attributes': - return static::getColumnImplementation($object); + return self::getColumnImplementation($object); case 'Columns': - return static::getColumnImplementation($object, true); + return self::getColumnImplementation($object, true); case 'HashOptions': - return static::getHashOptionsImplementation($object); + return self::getHashOptionsImplementation($object); } throw new Exception('Unknown union type: ' . $name); diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 85ae4fde25..7a2b6fe19a 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -492,6 +492,8 @@ class Realtime extends MessagingAdapter break; case 'databases': case 'tablesdb': + case 'documentsdb': + case 'vectorsdb': $resource = $parts[4] ?? ''; if (in_array($resource, ['columns', 'attributes', 'indexes'])) { $channels[] = 'console'; @@ -511,12 +513,20 @@ class Realtime extends MessagingAdapter $resourceId = $tableId ?: $collectionId; $channels = []; - // sending legacy + tablesdb events to both legacy and tablesdb - $channels = array_values(array_unique(array_merge( - self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'), - self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'), - self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId()) - ))); + switch ($parts[0]) { + case 'databases': + case 'tablesdb': + // sending legacy + tablesdb events to both legacy and tablesdb + $channels = array_values(array_unique(array_merge( + self::getDatabaseChannels('legacy', $database->getId(), $resourceId, $payload->getId(), 'databases'), + self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId(), 'databases'), + self::getDatabaseChannels('tablesdb', $database->getId(), $resourceId, $payload->getId()) + ))); + break; + default: + // only prefixed events + $channels = array_values(self::getDatabaseChannels($parts[0], $database->getId(), $resourceId, $payload->getId())); + } $roles = $collection->getAttribute('documentSecurity', false) ? \array_merge($collection->getRead(), $payload->getRead()) @@ -582,6 +592,7 @@ class Realtime extends MessagingAdapter * @param string $resourceId The collection/table ID * @param string $payloadId The document/row ID * @param string $prefixOverride Override the channel prefix when different API types share the same terminology but need different prefixes + * (e.g., 'databases' and 'documentsdb' use same terminology but need different prefixes) * @return array Array of channel names */ private static function getDatabaseChannels( @@ -615,6 +626,13 @@ class Realtime extends MessagingAdapter $channels[] = "{$basePrefix}.{$databaseId}.tables.{$resourceId}.rows.{$payloadId}"; break; + case 'documentsdb': + case 'vectorsdb': + $channels[] = 'documents'; + $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents"; + $channels[] = "{$basePrefix}.{$databaseId}.collections.{$resourceId}.documents.{$payloadId}"; + break; + default: $basePrefix = 'databases'; $channels[] = 'documents'; @@ -623,6 +641,7 @@ class Realtime extends MessagingAdapter break; } + return $channels; } } diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php index dc9ce9d196..a2d9d7907b 100644 --- a/src/Appwrite/Migration/Version/V24.php +++ b/src/Appwrite/Migration/Version/V24.php @@ -187,6 +187,20 @@ class V24 extends Migration $this->dbForProject->purgeCachedCollection($id); break; + case 'users': + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'impersonator'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"impersonator\" in collection {$id}: {$th->getMessage()}"); + } + try { + $this->createIndexFromCollection($this->dbForProject, $id, 'impersonator'); + } catch (Throwable $th) { + Console::warning("Failed to create index \"impersonator\" from {$id}: {$th->getMessage()}"); + } + $this->dbForProject->purgeCachedCollection($id); + break; + case 'teams': try { $this->createAttributeFromCollection($this->dbForProject, $id, 'labels'); diff --git a/src/Appwrite/Platform/Action.php b/src/Appwrite/Platform/Action.php index 01ac92a45c..0aa0da7149 100644 --- a/src/Appwrite/Platform/Action.php +++ b/src/Appwrite/Platform/Action.php @@ -37,7 +37,7 @@ class Action extends UtopiaAction * Foreach Document * Call provided callback for each document in the collection * - * @param string $projectId + * @param Database $database * @param string $collection * @param array $queries * @param callable $callback diff --git a/src/Appwrite/Platform/Appwrite.php b/src/Appwrite/Platform/Appwrite.php index 77b9c4d1dd..06312d9cb2 100644 --- a/src/Appwrite/Platform/Appwrite.php +++ b/src/Appwrite/Platform/Appwrite.php @@ -9,6 +9,7 @@ use Appwrite\Platform\Modules\Core; use Appwrite\Platform\Modules\Databases; use Appwrite\Platform\Modules\Functions; use Appwrite\Platform\Modules\Health; +use Appwrite\Platform\Modules\Project; use Appwrite\Platform\Modules\Projects; use Appwrite\Platform\Modules\Proxy; use Appwrite\Platform\Modules\Sites; @@ -38,5 +39,6 @@ class Appwrite extends Platform $this->addModule(new Storage\Module()); $this->addModule(new VCS\Module()); $this->addModule(new Webhooks\Module()); + $this->addModule(new Project\Module()); } } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php new file mode 100644 index 0000000000..ab0037f4b2 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Certificate/Get.php @@ -0,0 +1,91 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/install/certificate') + ->desc('Check if SSL certificate is ready for a domain') + ->param('domain', '', new AppDomain(), 'Domain to check') + ->param('port', 443, new Range(1, 65535), 'HTTPS port to check', true) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(string $domain, int $port, Response $response): void + { + $domain = trim($domain); + if ($domain === '') { + $response->json(['ready' => false]); + return; + } + + $ready = $this->checkHttps($domain, $port); + $response->json(['ready' => $ready]); + } + + private function checkHttps(string $domain, int $port): bool + { + $gateway = $this->getDockerGateway(); + + $ch = curl_init(); + $options = [ + CURLOPT_URL => 'https://' . $domain . ':' . $port . '/', + CURLOPT_NOBODY => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => self::CONNECTION_TIMEOUT_SECONDS, + CURLOPT_TIMEOUT => self::CONNECTION_TIMEOUT_SECONDS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + ]; + + if ($gateway !== '') { + $options[CURLOPT_RESOLVE] = [$domain . ':' . $port . ':' . $gateway]; + } + + curl_setopt_array($ch, $options); + curl_exec($ch); + $errno = curl_errno($ch); + curl_close($ch); + + return $errno === 0; + } + + private function getDockerGateway(): string + { + $route = @file_get_contents('/proc/net/route'); + if ($route === false) { + return ''; + } + + foreach (explode("\n", $route) as $line) { + $fields = preg_split('/\s+/', trim($line)); + if (isset($fields[1]) && $fields[1] === '00000000' && isset($fields[2])) { + $hex = $fields[2]; + if (strlen($hex) !== 8) { + continue; + } + $ip = long2ip((int) hexdec($hex[6] . $hex[7] . $hex[4] . $hex[5] . $hex[2] . $hex[3] . $hex[0] . $hex[1])); + return $ip; + } + } + + return ''; + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php index 92a00651fe..69f7d4b072 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php @@ -48,9 +48,10 @@ class Complete extends Action @touch(Server::INSTALLER_COMPLETE_FILE); - if (!$sessionSecret && $installId !== '') { - $data = $state->readProgressFile($installId); - $details = $data['details'][Server::STEP_ACCOUNT_SETUP] ?? []; + $progressData = ($installId !== '') ? $state->readProgressFile($installId) : []; + + if (!$sessionSecret) { + $details = $progressData['details'][Server::STEP_ACCOUNT_SETUP] ?? []; if (!empty($details['sessionSecret'])) { $sessionSecret = $details['sessionSecret']; $sessionId = $sessionId ?: ($details['sessionId'] ?? ''); @@ -68,8 +69,11 @@ class Complete extends Action $expires = $timestamp; } } - $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); - $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite); + $appDomain = $progressData['payload']['appDomain'] ?? ''; + $cookieDomain = $this->buildCookieDomain($appDomain ?: $request->getHostname()); + + $response->addCookie('a_session_console', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite); + $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite); if ($sessionId) { $response->addHeader('X-Appwrite-Session', $sessionId); } @@ -79,4 +83,42 @@ class Complete extends Action $response->json(['success' => true]); } + + /** + * Compute the cookie domain to match Appwrite's convention in general.php. + * + * For localhost and IP addresses the domain is left empty (host-only cookie). + * For real hostnames, the domain is prefixed with a dot so the cookie matches + * Appwrite's default `'.' . $request->getHostname()` behaviour and lives in + * the same cookie-jar slot — preventing stale ghost cookies after logout. + */ + private function buildCookieDomain(string $raw): string + { + $hostname = $this->extractHostname($raw); + if ($hostname === '' || $hostname === 'localhost' || $hostname === '0.0.0.0' || $hostname === 'traefik') { + return ''; + } + if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) { + return ''; + } + return '.' . $hostname; + } + + /** + * Extract the bare hostname from an appDomain value, stripping any port + * suffix or IPv6 bracket notation. + */ + private function extractHostname(string $domain): string + { + $domain = trim($domain); + if ($domain === '') { + return ''; + } + if (str_starts_with($domain, '[')) { + $end = strpos($domain, ']'); + return $end !== false ? substr($domain, 1, $end - 1) : ''; + } + $parts = explode(':', $domain); + return count($parts) <= 2 ? strtolower($parts[0]) : strtolower($domain); + } } diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php index 0b2fa17c0d..8aaaf621bb 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php @@ -35,7 +35,7 @@ class Install extends Action ->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)') ->param('httpPort', 80, new Range(1, 65535), 'HTTP port') ->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port') - ->param('emailCertificates', '', new Email(), 'Email for SSL certificates') + ->param('emailCertificates', '', new Email(allowEmpty: true), 'Email for SSL certificates', true) ->param('opensslKey', '', new Text(64, 0), 'Secret API key', true) ->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true) ->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true) @@ -43,6 +43,7 @@ class Install extends Action ->param('database', '', new WhiteList(['mongodb', 'mariadb', 'postgresql']), 'Database adapter', true) ->param('installId', '', new Text(64, 0), 'Installation ID', true) ->param('retryStep', null, new Nullable(new WhiteList([Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS], true)), 'Retry from step', true) + ->param('migrate', false, new \Utopia\Validator\Boolean(true), 'Run database migration after upgrade', true) ->inject('request') ->inject('response') ->inject('swooleResponse') @@ -64,6 +65,7 @@ class Install extends Action string $database, string $installId, ?string $retryStep, + bool $migrate, Request $request, Response $response, SwooleResponse $swooleResponse, @@ -90,6 +92,9 @@ class Install extends Action $appDomain = trim($appDomain); $emailCertificates = trim($emailCertificates); + if ($emailCertificates === '') { + $emailCertificates = trim($accountEmail); + } $opensslKey = trim($opensslKey); $assistantOpenAIKey = trim($assistantOpenAIKey); @@ -140,6 +145,8 @@ class Install extends Action @unlink(Server::INSTALLER_COMPLETE_FILE); + $state->clearStaleLockIfNeeded(); + try { $lockResult = $state->reserveGlobalLock($installId); } catch (\Throwable $e) { @@ -175,15 +182,23 @@ class Install extends Action if (file_exists($existingPath)) { $existing = $state->readProgressFile($installId); if (!empty($existing['steps']) && $retryStep === null) { - $state->updateGlobalLock($installId, Server::STATUS_ERROR); - if ($wantsStream) { - $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); - $swooleResponse->end(); + $previousHadError = isset($existing['error']); + $allCompleted = !$previousHadError && $this->allStepsCompleted($existing['steps']); + + if ($previousHadError || $allCompleted) { + @unlink($existingPath); + $existing = null; } else { - $response->setStatusCode(Response::STATUS_CODE_CONFLICT); - $response->json(['success' => false, 'message' => 'Installation already started']); + $state->updateGlobalLock($installId, Server::STATUS_ERROR); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']); + $swooleResponse->end(); + } else { + $response->setStatusCode(Response::STATUS_CODE_CONFLICT); + $response->json(['success' => false, 'message' => 'Installation already started']); + } + return; } - return; } } @@ -207,7 +222,8 @@ class Install extends Action '_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey, ]; - if ($this->hasPayload($existing)) { + $previousHadError = is_array($existing) && isset($existing['error']); + if ($this->hasPayload($existing) && !$previousHadError) { $stored = $existing['payload']; $inputValues = [ 'httpPort' => (string) $httpPort, @@ -307,6 +323,28 @@ class Install extends Action } }; + $responseSent = false; + $onComplete = function () use ($wantsStream, $swooleResponse, $response, $installId, $state, &$responseSent) { + if ($responseSent) { + return; + } + $responseSent = true; + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + if ($wantsStream) { + $this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->write(": keepalive\n\n"); + usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); + $swooleResponse->end(); + } else { + $response->json([ + 'success' => true, + 'installId' => $installId, + 'message' => 'Installation completed successfully', + ]); + } + }; + $installer->performInstallation( $httpPort ?: $config->getDefaultHttpPort(), $httpsPort ?: $config->getDefaultHttpsPort(), @@ -317,23 +355,12 @@ class Install extends Action $progress, $retryStep, $config->isUpgrade(), - $account + $account, + $onComplete, + $migrate, ); - if ($wantsStream) { - $this->writeSseEvent($swooleResponse, 'done', ['installId' => $installId, 'success' => true]); - usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); - $swooleResponse->write(": keepalive\n\n"); - usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS); - $swooleResponse->end(); - } else { - $response->json([ - 'success' => true, - 'installId' => $installId, - 'message' => 'Installation completed successfully', - ]); - } - $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + $onComplete(); } catch (\Throwable $e) { $this->handleInstallationError($e, $installId, $wantsStream, $response, $swooleResponse, $state); } @@ -368,8 +395,6 @@ class Install extends Action $state->updateGlobalLock($installId, Server::STATUS_ERROR); } - @unlink(Server::INSTALLER_CONFIG_FILE); - if ($wantsStream) { $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [ 'message' => $e->getMessage(), @@ -392,6 +417,16 @@ class Install extends Action return is_array($data) && isset($data['payload']) && is_array($data['payload']); } + private function allStepsCompleted(array $steps): bool + { + foreach ($steps as $step) { + if (($step['status'] ?? '') !== Server::STATUS_COMPLETED) { + return false; + } + } + return true; + } + private function deriveNameFromEmail(string $email): string { $parts = explode('@', $email); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Reset.php b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php new file mode 100644 index 0000000000..8e5b877473 --- /dev/null +++ b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php @@ -0,0 +1,110 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/install/reset') + ->desc('Reset installation state') + ->param('installId', '', new Text(64, 0), 'Installation ID', true) + ->param('hard', false, new Boolean(true), 'Remove all data including volumes and config files', true) + ->inject('request') + ->inject('response') + ->inject('installerState') + ->inject('installerConfig') + ->callback($this->action(...)); + } + + public function action(string $installId, bool $hard, Request $request, Response $response, State $state, Config $config): void + { + if (!Validate::validateCsrf($request)) { + $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); + $response->json(['success' => false, 'message' => 'Invalid CSRF token']); + return; + } + + $installId = $state->sanitizeInstallId($installId); + + if ($installId !== '') { + @unlink($state->progressFilePath($installId)); + $state->updateGlobalLock($installId, Server::STATUS_COMPLETED); + } + + // Use direct clearStaleLock (not throttled) since reset is an + // explicit user action that should guarantee all stale state is gone. + $state->clearStaleLock(); + + if ($hard) { + $error = $this->performHardReset($config); + if ($error !== null) { + $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR); + $response->json(['success' => false, 'message' => $error]); + return; + } + } + + $response->json(['success' => true]); + } + + private function performHardReset(Config $config): ?string + { + $isLocal = $config->isLocal(); + $composeFileName = $isLocal ? 'docker-compose.web-installer.yml' : 'docker-compose.yml'; + $envFileName = $isLocal ? '.env.web-installer' : '.env'; + $path = $isLocal ? '/usr/src/code' : '/usr/src/code/appwrite'; + + $composeFile = $path . '/' . $composeFileName; + + if (file_exists($composeFile)) { + $command = array_map(escapeshellarg(...), [ + 'docker', 'compose', + '-f', $composeFile, + ...($isLocal ? ['--project-name', 'appwrite'] : []), + '--project-directory', $path, + 'down', '-v', '--remove-orphans', + ]); + + $output = []; + @exec(implode(' ', $command) . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + return 'Failed to stop containers: ' . trim(implode("\n", $output)); + } + + @unlink($composeFile); + } + + $envFile = $path . '/' . $envFileName; + if (file_exists($envFile)) { + @unlink($envFile); + } + + @unlink(Server::INSTALLER_CONFIG_FILE); + @unlink(Server::INSTALLER_LOCK_FILE); + + $tempDir = sys_get_temp_dir(); + foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) { + @unlink($file); + } + + return null; + } +} diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php index e53a501f4c..d6ffa64c8f 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php @@ -28,6 +28,8 @@ class Status extends Action public function action(string $installId, Response $response, State $state): void { + $state->clearStaleLockIfNeeded(); + $installId = $state->sanitizeInstallId($installId); if ($installId === '') { $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST); diff --git a/src/Appwrite/Platform/Installer/Http/Installer/View.php b/src/Appwrite/Platform/Installer/Http/Installer/View.php index ce308aa906..dea356eaaf 100644 --- a/src/Appwrite/Platform/Installer/Http/Installer/View.php +++ b/src/Appwrite/Platform/Installer/Http/Installer/View.php @@ -24,7 +24,7 @@ class View extends Action ->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) ->setHttpPath('/') ->desc('Serve installer UI') - ->param('step', 1, new Integer(true), 'Step number (1-5)', true) + ->param('step', 1, new Integer(true), 'Step number (1-6)', true) ->param('partial', null, new Nullable(new Text(1, 0)), 'Render partial step only', true) ->inject('request') ->inject('response') @@ -52,10 +52,13 @@ class View extends Action $defaultEmailCertificates = 'walterobrien@example.com'; } - $step = max(1, min(5, $step)); + $step = max(1, min(6, $step)); if ($isUpgrade && ($step === 2 || $step === 3)) { $step = 4; } + if (!$isUpgrade && $step === 6) { + $step = 4; + } $partialFile = $paths['views'] . "/installer/templates/steps/step-{$step}.phtml"; if (!is_file($partialFile)) { diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php index 5552eb5632..75efd7027c 100644 --- a/src/Appwrite/Platform/Installer/Runtime/State.php +++ b/src/Appwrite/Platform/Installer/Runtime/State.php @@ -13,13 +13,15 @@ class State private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/'; private const int CONFIG_FILE_PERMISSION = 0600; - private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600; + private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 300; + private const int STALE_LOCK_CHECK_INTERVAL_SECONDS = 30; private const int PORT_MIN = 1; private const int PORT_MAX = 65535; private array $paths; private bool $bootstrapped = false; + private int $lastStaleLockClearAt = 0; public function __construct(array $paths) { @@ -254,6 +256,16 @@ class State } } + public function clearStaleLockIfNeeded(): void + { + $now = time(); + if ($now - $this->lastStaleLockClearAt < self::STALE_LOCK_CHECK_INTERVAL_SECONDS) { + return; + } + $this->lastStaleLockClearAt = $now; + $this->clearStaleLock(); + } + public function reserveGlobalLock(string $installId): string { return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) { diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php index f36c270553..6d9cd5412f 100644 --- a/src/Appwrite/Platform/Installer/Server.php +++ b/src/Appwrite/Platform/Installer/Server.php @@ -3,8 +3,10 @@ namespace Appwrite\Platform\Installer; use Appwrite\Platform\Installer\Http\Installer\Error; +use Appwrite\Platform\Installer\Runtime\Config; use Appwrite\Platform\Installer\Runtime\State; use Swoole\Http\Server as SwooleServer; +use Swoole\Runtime; use Utopia\Http\Adapter\Swoole\Request; use Utopia\Http\Adapter\Swoole\Response; use Utopia\Http\Adapter\Swoole\Server as SwooleAdapter; @@ -27,6 +29,8 @@ class Server public const string STEP_DOCKER_COMPOSE = 'docker-compose'; public const string STEP_DOCKER_CONTAINERS = 'docker-containers'; public const string STEP_ACCOUNT_SETUP = 'account-setup'; + public const string STEP_MIGRATION = 'migration'; + public const string STEP_SSL_CERTIFICATE = 'ssl-certificate'; public const string STATUS_IN_PROGRESS = 'in-progress'; public const string STATUS_COMPLETED = 'completed'; @@ -127,6 +131,8 @@ class Server private function startSwooleServer(string $host, int $port, ?string $readyFile = null): void { + Runtime::enableCoroutine(SWOOLE_HOOK_ALL); + $this->state->clearStaleLock(); // Preload static files into memory @@ -135,6 +141,7 @@ class Server // Register resources for dependency injection into actions $config = $this->state->buildConfig(); + $this->autoDetectUpgrade($config); $paths = $this->paths; $state = $this->state; @@ -190,6 +197,77 @@ class Server $adapter->start(); } + /** + * Auto-detect upgrade mode by checking for existing config files. + * Sets isUpgrade and lockedDatabase on the config when an existing + * installation is found and these values aren't already set. + */ + private function autoDetectUpgrade(Config $config): void + { + if ($config->isUpgrade()) { + return; + } + + $basePath = $config->isLocal() ? '/usr/src/code' : (getcwd() ?: '.'); + $composePath = $basePath . '/docker-compose.yml'; + $envPath = $basePath . '/.env'; + + if (!file_exists($composePath) && !file_exists($envPath)) { + return; + } + + $config->setIsUpgrade(true); + + if ($config->getLockedDatabase() !== null) { + return; + } + + $database = $this->detectDatabaseFromFiles($composePath, $envPath); + if ($database !== null) { + $config->setLockedDatabase($database); + } + } + + private function detectDatabaseFromFiles(string $composePath, string $envPath): ?string + { + $dbServices = ['mariadb', 'mongodb', 'postgresql']; + + $composeData = @file_get_contents($composePath); + if ($composeData !== false) { + if (preg_match_all('/^\s*(?:container_name:\s*appwrite-(\w+)|(\w+):)\s*$/m', $composeData, $matches)) { + $serviceNames = array_filter(array_merge($matches[1], $matches[2])); + foreach ($dbServices as $db) { + if (in_array($db, $serviceNames, true)) { + return $db; + } + } + } + foreach ($dbServices as $db) { + if (preg_match('/^\s*' . preg_quote($db, '/') . ':\s*$/m', $composeData)) { + return $db; + } + } + } + + $envData = @file_get_contents($envPath); + if ($envData !== false) { + if (preg_match('/^_APP_DB_ADAPTER=(.+)$/m', $envData, $m)) { + $adapter = trim($m[1], " \t\n\r\"'"); + if (in_array($adapter, $dbServices, true)) { + return $adapter; + } + } + if (preg_match('/^_APP_DB_HOST=(.+)$/m', $envData, $m)) { + $host = trim($m[1], " \t\n\r\"'"); + if (in_array($host, $dbServices, true)) { + return $host; + } + } + } + + return null; + } + private function removeDockerInstallerContainer(string $container): void { $name = escapeshellarg($container); diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php index bd0fc62cdc..b410e67a26 100644 --- a/src/Appwrite/Platform/Installer/Services/Http.php +++ b/src/Appwrite/Platform/Installer/Services/Http.php @@ -2,8 +2,10 @@ namespace Appwrite\Platform\Installer\Services; +use Appwrite\Platform\Installer\Http\Installer\Certificate\Get as CertificateGet; use Appwrite\Platform\Installer\Http\Installer\Complete; use Appwrite\Platform\Installer\Http\Installer\Install; +use Appwrite\Platform\Installer\Http\Installer\Reset; use Appwrite\Platform\Installer\Http\Installer\Shutdown; use Appwrite\Platform\Installer\Http\Installer\Status; use Appwrite\Platform\Installer\Http\Installer\Validate; @@ -21,6 +23,8 @@ class Http extends Service $this->addAction(Validate::getName(), new Validate()); $this->addAction(Complete::getName(), new Complete()); $this->addAction(Shutdown::getName(), new Shutdown()); + $this->addAction(Reset::getName(), new Reset()); $this->addAction(Install::getName(), new Install()); + $this->addAction(CertificateGet::getName(), new CertificateGet()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Constants.php b/src/Appwrite/Platform/Modules/Databases/Constants.php index cfc297c3f4..edc6b09cf0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Constants.php +++ b/src/Appwrite/Platform/Modules/Databases/Constants.php @@ -22,3 +22,11 @@ const INDEX = 'index'; const DOCUMENTS = 'document'; const ATTRIBUTES = 'attribute'; const COLLECTIONS = 'collection'; + +const LEGACY = 'legacy'; +const TABLESDB = 'tablesdb'; +const DOCUMENTSDB = 'documentsdb'; +const VECTORSDB = 'vectorsdb'; + +const MIN_VECTOR_DIMENSION = 1; +const MAX_VECTOR_DIMENSION = 16000; diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php index 728e732cc5..b2417871ed 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Action.php @@ -10,7 +10,7 @@ use Utopia\Database\Operator; class Action extends AppwriteAction { - private string $context = 'legacy'; + private string $context = DATABASE_TYPE_LEGACY; public function getDatabaseType(): string { @@ -20,7 +20,13 @@ class Action extends AppwriteAction public function setHttpPath(string $path): AppwriteAction { if (\str_contains($path, '/tablesdb')) { - $this->context = 'tablesdb'; + $this->context = DATABASE_TYPE_TABLESDB; + } + if (\str_contains($path, '/documentsdb')) { + $this->context = DATABASE_TYPE_DOCUMENTSDB; + } + if (\str_contains($path, '/vectorsdb')) { + $this->context = DATABASE_TYPE_VECTORSDB; } return parent::setHttpPath($path); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php index f49d07ec4c..2f541936a8 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Action.php @@ -15,6 +15,8 @@ abstract class Action extends UtopiaAction */ private ?string $context = COLLECTIONS; + private ?string $databaseType = LEGACY; + /** * Get the response model used in the SDK and HTTP responses. */ @@ -24,6 +26,9 @@ abstract class Action extends UtopiaAction { if (\str_contains($path, '/tablesdb')) { $this->context = TABLES; + $this->databaseType = TABLESDB; + } elseif (\str_contains($path, '/vectorsdb')) { + $this->databaseType = VECTORSDB; } return parent::setHttpPath($path); } @@ -36,6 +41,14 @@ abstract class Action extends UtopiaAction return $this->context; } + /** + * Get the current API database type. + */ + protected function getDatabaseType(): string + { + return $this->databaseType; + } + /** * Get the key used in event parameters (e.g., 'collectionId' or 'tableId'). */ 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 216ec07e05..fd309a413c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Create.php @@ -84,12 +84,13 @@ class Create extends Action ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $name, ?array $permissions, bool $documentSecurity, bool $enabled, array $attributes, array $indexes, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -121,12 +122,18 @@ class Create extends Action throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); } + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($database); + $collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $databaseKey = 'database_' . $database->getSequence(); $attributesValidator = new AttributesValidator( APP_LIMIT_ARRAY_PARAMS_SIZE, - $dbForProject->getAdapter()->getSupportForSpatialAttributes() + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForAttributes() ); if (!$attributesValidator->isValid($attributes)) { @@ -155,7 +162,7 @@ class Create extends Action } // Validate indexes - $indexesValidator = new IndexesValidator($dbForProject->getLimitForIndexes()); + $indexesValidator = new IndexesValidator($dbForDatabases->getLimitForIndexes()); if (!$indexesValidator->isValid($indexes)) { $dbForProject->deleteDocument($databaseKey, $collection->getId()); throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, $indexesValidator->getDescription()); @@ -178,21 +185,23 @@ class Create extends Action $indexValidator = new IndexValidator( $collectionAttributes, [], - $dbForProject->getAdapter()->getMaxIndexLength(), - $dbForProject->getAdapter()->getInternalIndexesKeys(), - $dbForProject->getAdapter()->getSupportForIndexArray(), - $dbForProject->getAdapter()->getSupportForSpatialIndexNull(), - $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForProject->getAdapter()->getSupportForVectors(), - $dbForProject->getAdapter()->getSupportForAttributes(), - $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForProject->getAdapter()->getSupportForIdenticalIndexes(), - $dbForProject->getAdapter()->getSupportForObjectIndexes(), - $dbForProject->getAdapter()->getSupportForTrigramIndex(), - $dbForProject->getAdapter()->getSupportForSpatialAttributes(), - $dbForProject->getAdapter()->getSupportForIndex(), - $dbForProject->getAdapter()->getSupportForUniqueIndex(), - $dbForProject->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject(), ); foreach ($collectionIndexes as $indexDoc) { @@ -203,7 +212,7 @@ class Create extends Action } try { - $dbForProject->createCollection( + $dbForDatabases->createCollection( id: $collectionKey, attributes: $collectionAttributes, indexes: $collectionIndexes, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php index 7f194aa93d..7a5b73f7db 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Delete.php @@ -62,13 +62,14 @@ class Delete extends Action ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -85,7 +86,8 @@ class Delete extends Action throw new Exception(Exception::GENERAL_SERVER_ERROR, "Failed to remove $type from DB"); } - $dbForProject->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->purgeCachedCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); $queueForDatabase ->setType(DATABASE_TYPE_DELETE_COLLECTION) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php index 39146508fb..0bd4a2e080 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Action.php @@ -17,6 +17,7 @@ abstract class Action extends DatabasesAction * @var string|null The current context (either 'row' or 'document') */ private ?string $context = DOCUMENTS; + private ?string $databaseType = DATABASE_TYPE_LEGACY; /** * Get the response model used in the SDK and HTTP responses. @@ -27,6 +28,10 @@ abstract class Action extends DatabasesAction { if (str_contains($path, '/tablesdb/')) { $this->context = ROWS; + } elseif (str_contains($path, '/documentsdb/')) { + $this->databaseType = DATABASE_TYPE_DOCUMENTSDB; + } elseif (str_contains($path, '/vectorsdb/')) { + $this->databaseType = DATABASE_TYPE_VECTORSDB; } $contextId = '$' . $this->getCollectionsEventsContext() . 'Id'; @@ -45,6 +50,39 @@ abstract class Action extends DatabasesAction return parent::setHttpPath($path); } + protected function getDatabasesOperationReadMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASES_OPERATIONS_READS; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_READS; + } + + protected function getDatabasesIdOperationReadMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_READS; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_READS; + } + + protected function getDatabasesOperationWriteMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASES_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES; + + } + + protected function getDatabasesIdOperationWriteMetric(): string + { + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + /** * Get the plural of the given name. * diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php index 54557eaac0..a02eb51aba 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Decrement.php @@ -82,17 +82,19 @@ class Decrement extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $min, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void { - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -170,8 +172,9 @@ class Decrement extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { - $document = $dbForProject->decreaseDocumentAttribute( + $document = $dbForDatabases->decreaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), id: $documentId, attribute: $attribute, @@ -201,8 +204,8 @@ class Decrement extends Action ); $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + ->addMetric($this->getDatabasesOperationWriteMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); $queueForEvents ->setParam('databaseId', $databaseId) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php index b9c19b2d06..305d9b7a8d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Attribute/Increment.php @@ -82,17 +82,19 @@ class Increment extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string $attribute, int|float $value, int|float|null $max, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, array $plan, Authorization $authorization, User $user): void { - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -170,8 +172,9 @@ class Increment extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { - $document = $dbForProject->increaseDocumentAttribute( + $document = $dbForDatabases->increaseDocumentAttribute( collection: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), id: $documentId, attribute: $attribute, @@ -201,8 +204,8 @@ class Increment extends Action ); $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, 1) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), 1); + ->addMetric($this->getDatabasesOperationWriteMetric(), 1) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), 1); $queueForEvents ->setParam('databaseId', $databaseId) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php index f45b126f16..267a54adb0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Delete.php @@ -76,6 +76,7 @@ class Delete extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -86,7 +87,7 @@ class Delete extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -163,10 +164,11 @@ class Delete extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $documents = []; try { - $modified = $dbForProject->deleteDocuments( + $modified = $dbForDatabases->deleteDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $queries, onNext: function (Document $document) use ($plan, &$documents) { @@ -189,12 +191,12 @@ class Delete extends Action } $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, - $this->getSDKGroup() => $documents, + $this->getSDKGroup() => $documents ]), $this->getResponseModel()); $this->triggerBulk( diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php index 000b59ff07..da3adf1192 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Update.php @@ -80,6 +80,7 @@ class Update extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -90,7 +91,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, string|array $data, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -189,11 +190,12 @@ class Update extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $documents = []; try { - $modified = $dbForProject->withPreserveDates(function () use ($plan, &$documents, $dbForProject, $database, $collection, $data, $queries) { - return $dbForProject->updateDocuments( + $modified = $dbForDatabases->withPreserveDates(function () use ($plan, &$documents, $dbForDatabases, $database, $collection, $data, $queries) { + return $dbForDatabases->updateDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), new Document($data), $queries, @@ -220,8 +222,8 @@ class Update extends Action } $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php index 564b5ee7b6..5a5ebf48ee 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Bulk/Upsert.php @@ -78,6 +78,7 @@ class Upsert extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') @@ -88,7 +89,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $collectionId, array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, Event $queueForEvents, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, EventProcessor $eventProcessor): void { $database = $dbForProject->getDocument('databases', $databaseId); if ($database->isEmpty()) { @@ -165,11 +166,12 @@ class Upsert extends Action return; } + $dbForDatabases = $getDatabasesDB($database); $upserted = []; try { - $modified = $dbForProject->withPreserveDates(function () use ($dbForProject, $database, $collection, $documents, $plan, &$upserted) { - return $dbForProject->upsertDocuments( + $modified = $dbForDatabases->withPreserveDates(function () use ($dbForDatabases, $database, $collection, $documents, $plan, &$upserted) { + return $dbForDatabases->upsertDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documents, onNext: function (Document $document) use ($plan, &$upserted) { @@ -195,8 +197,8 @@ class Upsert extends Action } $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $modified)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $modified)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $modified)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $modified)); $response->dynamic(new Document([ 'total' => $modified, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php index 0bbe7c75cf..7f2e895228 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Create.php @@ -85,7 +85,7 @@ class Create extends Action new Parameter('documentId', optional: false), new Parameter('data', optional: false), new Parameter('permissions', optional: true), - new Parameter('transactionId', optional: true), + new Parameter('transactionId', optional: true) ], deprecated: new Deprecated( since: '1.8.0', @@ -110,7 +110,7 @@ class Create extends Action new Parameter('databaseId', optional: false), new Parameter('collectionId', optional: false), new Parameter('documents', optional: false), - new Parameter('transactionId', optional: true), + new Parameter('transactionId', optional: true) ], deprecated: new Deprecated( since: '1.8.0', @@ -127,6 +127,7 @@ class Create extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('queueForEvents') ->inject('usage') @@ -138,7 +139,7 @@ class Create extends Action ->inject('eventProcessor') ->callback($this->action(...)); } - public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Document $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $databaseId, string $documentId, string $collectionId, string|array $data, ?array $permissions, ?array $documents, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, array $plan, Authorization $authorization, EventProcessor $eventProcessor): void { $data = \is_string($data) ? \json_decode($data, true) @@ -182,8 +183,8 @@ class Create extends Action $documents = [$data]; } - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($isBulk && !$isAPIKey && !$isPrivilegedUser) { throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE); @@ -447,11 +448,12 @@ class Create extends Action return; } + $dbForDatabases = $getDatabasesDB($database); try { $created = []; - $dbForProject->withPreserveDates( - function () use (&$created, $dbForProject, $database, $collection, $documents) { - $dbForProject->createDocuments( + $dbForDatabases->withPreserveDates( + function () use (&$created, $dbForDatabases, $database, $collection, $documents) { + $dbForDatabases->createDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documents, onNext: function ($doc) use (&$created) { @@ -490,15 +492,15 @@ class Create extends Action } $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); // per collection + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // per collection $response->setStatusCode(SwooleResponse::STATUS_CODE_CREATED); if ($isBulk) { $response->dynamic(new Document([ 'total' => count($created), - $this->getSdkGroup() => $created + $this->getSDKGroup() => $created ]), $this->getBulkResponseModel()); $this->triggerBulk( diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php index 0996fa24ab..ecc5b152ec 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Delete.php @@ -79,11 +79,13 @@ class Delete extends Action ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -95,16 +97,18 @@ class Delete extends Action ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, + callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, - Authorization $authorization + Authorization $authorization, + User $user ): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); @@ -116,14 +120,15 @@ class Delete extends Action throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } + $dbForDatabases = $getDatabasesDB($database); // Read permission should not be required for delete $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($document->isEmpty()) { @@ -187,8 +192,8 @@ class Delete extends Action } try { - $dbForProject->withRequestTimestamp($requestTimestamp, function () use ($dbForProject, $database, $collection, $documentId) { - $dbForProject->deleteDocument( + $dbForDatabases->withRequestTimestamp($requestTimestamp, function () use ($dbForDatabases, $database, $collection, $documentId) { + $dbForDatabases->deleteDocument( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId ); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php index 10de481072..b48df136ee 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php @@ -68,16 +68,18 @@ class Get extends Action ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, ?string $transactionId, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization, User $user): void { - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { @@ -86,6 +88,7 @@ class Get extends Action $collection = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId)); + $dbForDatabases = $getDatabasesDB($database); if ($collection->isEmpty() || (!$collection->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } @@ -99,14 +102,17 @@ class Get extends Action try { $selects = Query::groupByType($queries)['selections'] ?? []; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries); - } elseif (!empty($selects)) { - $document = $dbForProject->getDocument($collectionTableId, $documentId, $queries); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId, $queries); + } elseif (! empty($selects)) { + // has selects, allow relationship on documents! + $document = $dbForDatabases->getDocument($collectionTableId, $documentId, $queries); } else { - $document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries)); + // has no selects, disable relationship looping on documents! + $document = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId, $queries)); } } catch (QueryException $e) { throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); @@ -129,8 +135,8 @@ class Get extends Action ); $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); $response->addHeader('X-Debug-Operations', $operations); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php index 5d75c67462..8aaac5fcb4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Logs/XList.php @@ -70,6 +70,7 @@ class XList extends Action ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('locale') ->inject('geodb') ->inject('authorization') @@ -77,7 +78,7 @@ class XList extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void + public function action(string $databaseId, string $collectionId, string $documentId, array $queries, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Locale $locale, Reader $geodb, Authorization $authorization, Audit $audit): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -89,7 +90,8 @@ class XList extends Action throw new Exception($this->getParentNotFoundException(), params: [$collectionId]); } - $document = $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId); + $dbForDatabases = $getDatabasesDB($database); + $document = $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId); if ($document->isEmpty()) { throw new Exception($this->getNotFoundException(), params: [$documentId]); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php index ca7935dfbd..27ccaafc71 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Update.php @@ -83,15 +83,17 @@ class Update extends Action ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -101,8 +103,8 @@ class Update extends Action $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::DATABASE_NOT_FOUND, params: [$databaseId]); @@ -118,15 +120,16 @@ class Update extends Action $data = $this->parseOperators($data, $collection); } + $dbForDatabases = $getDatabasesDB($database); // Read permission should not be required for update /** @var Document $document */ $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $document = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $document = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($document->isEmpty()) { @@ -247,8 +250,8 @@ class Update extends Action $setCollection($collection, $newDocument); $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), $operations); + ->addMetric($this->getDatabasesOperationWriteMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), $operations); // Handle transaction staging if ($transactionId !== null) { @@ -319,9 +322,9 @@ class Update extends Action try { - $document = $dbForProject->withRequestTimestamp( + $document = $dbForDatabases->withRequestTimestamp( $requestTimestamp, - fn () => $dbForProject->withPreserveDates(fn () => $dbForProject->updateDocument( + fn () => $dbForDatabases->withPreserveDates(fn () => $dbForDatabases->updateDocument( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $document->getId(), $newDocument diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php index dc6655dfd3..ef89b80e97 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Upsert.php @@ -87,6 +87,7 @@ class Upsert extends Action ->inject('response') ->inject('user') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') @@ -95,7 +96,7 @@ class Upsert extends Action ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, Document $user, Database $dbForProject, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $documentId, string|array $data, ?array $permissions, ?string $transactionId, ?\DateTime $requestTimestamp, UtopiaResponse $response, User $user, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Context $usage, TransactionState $transactionState, array $plan, Authorization $authorization): void { $data = (\is_string($data)) ? \json_decode($data, true) : $data; // Cast to JSON array @@ -107,8 +108,8 @@ class Upsert extends Action throw new Exception($this->getMissingPayloadException()); } - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { @@ -124,6 +125,7 @@ class Upsert extends Action $data = $this->parseOperators($data, $collection); } + $dbForDatabases = $getDatabasesDB($database); $allowedPermissions = [ Database::PERMISSION_READ, Database::PERMISSION_UPDATE, @@ -134,13 +136,15 @@ class Upsert extends Action $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); + // If no permission, upsert permission from the old document if present (update scenario) else add default permission (create scenario) if (\is_null($permissions)) { if ($transactionId !== null) { // Use transaction-aware document retrieval to see changes from same transaction - $oldDocument = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $oldDocument = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument($collectionTableId, $documentId)); + $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument($collectionTableId, $documentId)); } if ($oldDocument->isEmpty()) { if (!empty($user->getId())) { @@ -182,7 +186,7 @@ class Upsert extends Action $newDocument = new Document($data); $operations = 0; - $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $database, &$operations, $authorization) { + $setCollection = (function (Document $collection, Document $document) use ($isAPIKey, $isPrivilegedUser, &$setCollection, $dbForProject, $dbForDatabases, $database, &$operations, $authorization) { $operations++; $relationships = \array_filter( @@ -226,7 +230,7 @@ class Upsert extends Action if ($relation instanceof Document) { $relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser); - $oldDocument = $authorization->skip(fn () => $dbForProject->getDocument( + $oldDocument = $authorization->skip(fn () => $dbForDatabases->getDocument( 'database_' . $database->getSequence() . '_collection_' . $relatedCollection->getSequence(), $relation->getId() )); @@ -257,8 +261,8 @@ class Upsert extends Action $setCollection($collection, $newDocument); $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, \max(1, $operations)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES), \max(1, $operations)); + ->addMetric($this->getDatabasesOperationWriteMetric(), \max(1, $operations)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationWriteMetric()), \max(1, $operations)); // Handle transaction staging if ($transactionId !== null) { @@ -327,8 +331,8 @@ class Upsert extends Action $upserted = []; try { - $dbForProject->withPreserveDates(function () use (&$upserted, $dbForProject, $database, $collection, $newDocument) { - return $dbForProject->upsertDocuments( + $dbForDatabases->withPreserveDates(function () use (&$upserted, $dbForDatabases, $database, $collection, $newDocument) { + return $dbForDatabases->upsertDocuments( 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), [$newDocument], onNext: function (Document $document) use (&$upserted) { @@ -351,9 +355,9 @@ class Upsert extends Action if (empty($upserted[0])) { if ($transactionId !== null) { // For transactions, get the document with transaction changes applied - $upserted[0] = $transactionState->getDocument($collectionTableId, $documentId, $transactionId); + $upserted[0] = $transactionState->getDocument($database, $collectionTableId, $documentId, $transactionId); } else { - $upserted[0] = $dbForProject->getDocument($collectionTableId, $documentId); + $upserted[0] = $dbForDatabases->getDocument($collectionTableId, $documentId); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php index a7d77d8a93..744a4fd922 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php @@ -76,16 +76,17 @@ class XList extends Action ->inject('response') ->inject('dbForProject') ->inject('user') + ->inject('getDatabasesDB') ->inject('usage') ->inject('transactionState') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, Document $user, Context $usage, TransactionState $transactionState, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, array $queries, ?string $transactionId, bool $includeTotal, int $ttl, UtopiaResponse $response, Database $dbForProject, User $user, callable $getDatabasesDB, Context $usage, TransactionState $transactionState, Authorization $authorization): void { - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty() || (!$database->getAttribute('enabled', false) && !$isAPIKey && !$isPrivilegedUser)) { @@ -103,6 +104,7 @@ class XList extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } + $dbForDatabases = $getDatabasesDB($database); $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -114,7 +116,7 @@ class XList extends Action $documentId = $cursor->getValue(); - $cursorDocument = $authorization->skip(fn () => $dbForProject->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); + $cursorDocument = $authorization->skip(fn () => $dbForDatabases->getDocument('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $documentId)); if ($cursorDocument->isEmpty()) { $type = ucfirst($this->getContext()); @@ -127,11 +129,10 @@ class XList extends Action try { $selectQueries = Query::groupByType($queries)['selections'] ?? []; $collectionTableId = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); - // Use transaction-aware document retrieval if transactionId is provided if ($transactionId !== null) { - $documents = $transactionState->listDocuments($collectionTableId, $transactionId, $queries); - $total = $includeTotal ? $transactionState->countDocuments($collectionTableId, $transactionId, $queries) : 0; + $documents = $transactionState->listDocuments($database, $collectionTableId, $transactionId, $queries); + $total = $includeTotal ? $transactionState->countDocuments($database, $collectionTableId, $transactionId, $queries) : 0; } elseif (! empty($selectQueries)) { if ((int)$ttl > 0) { @@ -170,7 +171,7 @@ class XList extends Action }, $cachedDocuments); $documentsCacheHit = true; } else { - $documents = $dbForProject->find($collectionTableId, $queries); + $documents = $dbForDatabases->find($collectionTableId, $queries); // Convert Document objects to arrays for caching $documentsArray = \array_map(function ($doc) { @@ -196,15 +197,15 @@ class XList extends Action } else { // has selects, allow relationship on documents - $documents = $dbForProject->find($collectionTableId, $queries); - $total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $dbForDatabases->find($collectionTableId, $queries); + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } else { // has no selects, disable relationship loading on documents /* @type Document[] $documents */ - $documents = $dbForProject->skipRelationships(fn () => $dbForProject->find($collectionTableId, $queries)); - $total = $includeTotal ? $dbForProject->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; + $documents = $dbForDatabases->skipRelationships(fn () => $dbForDatabases->find($collectionTableId, $queries)); + $total = $includeTotal ? $dbForDatabases->count($collectionTableId, $queries, APP_LIMIT_COUNT) : 0; } } catch (OrderException $e) { $documents = $this->isCollectionsAPI() ? 'documents' : 'rows'; @@ -232,8 +233,8 @@ class XList extends Action } $usage - ->addMetric(METRIC_DATABASES_OPERATIONS_READS, max($operations, 1)) - ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), $operations); + ->addMetric($this->getDatabasesOperationReadMetric(), max($operations, 1)) + ->addMetric(str_replace('{databaseInternalId}', $database->getSequence(), $this->getDatabasesIdOperationReadMetric()), $operations); $response->dynamic(new Document([ 'total' => $total, diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php index fd785f3609..7e073c95d4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Indexes/Create.php @@ -77,13 +77,14 @@ class Create extends Action ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, string $key, string $type, array $attributes, array $orders, array $lengths, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, EventDatabase $queueForDatabase, Event $queueForEvents, Authorization $authorization): void { $db = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); @@ -103,7 +104,9 @@ class Create extends Action Query::equal('databaseInternalId', [$db->getSequence()]) ], 61); - $limit = $dbForProject->getLimitForIndexes(); + $dbForDatabases = $getDatabasesDB($db); + + $limit = $dbForDatabases->getLimitForIndexes(); if ($count >= $limit) { throw new Exception($this->getLimitException(), params: [$collectionId]); @@ -145,32 +148,35 @@ class Create extends Action ]; $contextType = $this->getParentContext(); - foreach ($attributes as $i => $attribute) { - $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); + if ($dbForDatabases->getAdapter()->getSupportForAttributes()) { + foreach ($attributes as $i => $attribute) { + // find attribute metadata in collection document + $attributeIndex = \array_search($attribute, array_column($oldAttributes, 'key')); - if ($attributeIndex === false) { - throw new Exception($this->getParentUnknownException(), params: [$attribute]); - } + if ($attributeIndex === false) { + throw new Exception($this->getParentUnknownException(), params: [$attribute]); + } - $attributeStatus = $oldAttributes[$attributeIndex]['status']; - $attributeType = $oldAttributes[$attributeIndex]['type']; - $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; + $attributeStatus = $oldAttributes[$attributeIndex]['status']; + $attributeType = $oldAttributes[$attributeIndex]['type']; + $attributeArray = $oldAttributes[$attributeIndex]['array'] ?? false; - if ($attributeType === Database::VAR_RELATIONSHIP) { - throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); - } + if ($attributeType === Database::VAR_RELATIONSHIP) { + throw new Exception($this->getParentInvalidTypeException(), "Cannot create an index for a relationship $contextType: " . $oldAttributes[$attributeIndex]['key']); + } - if ($attributeStatus !== 'available') { - throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); - } + if ($attributeStatus !== 'available') { + throw new Exception($this->getParentNotAvailableException(), params: [$oldAttributes[$attributeIndex]['key']]); + } - if (empty($lengths[$i])) { - $lengths[$i] = null; - } + if (empty($lengths[$i])) { + $lengths[$i] = null; + } - if ($attributeArray === true) { - // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. - throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + if ($attributeArray === true) { + // Because of a bug in MySQL, we cannot create indexes on array attributes for now, otherwise queries break. + throw new Exception(Exception::INDEX_INVALID, 'Creating indexes on array attributes is not currently supported.'); + } } } @@ -191,21 +197,23 @@ class Create extends Action $validator = new IndexValidator( $collection->getAttribute('attributes'), $collection->getAttribute('indexes'), - $dbForProject->getAdapter()->getMaxIndexLength(), - $dbForProject->getAdapter()->getInternalIndexesKeys(), - $dbForProject->getAdapter()->getSupportForIndexArray(), - $dbForProject->getAdapter()->getSupportForSpatialIndexNull(), - $dbForProject->getAdapter()->getSupportForSpatialIndexOrder(), - $dbForProject->getAdapter()->getSupportForVectors(), - $dbForProject->getAdapter()->getSupportForAttributes(), - $dbForProject->getAdapter()->getSupportForMultipleFulltextIndexes(), - $dbForProject->getAdapter()->getSupportForIdenticalIndexes(), - $dbForProject->getAdapter()->getSupportForObjectIndexes(), - $dbForProject->getAdapter()->getSupportForTrigramIndex(), - $dbForProject->getAdapter()->getSupportForSpatialAttributes(), - $dbForProject->getAdapter()->getSupportForIndex(), - $dbForProject->getAdapter()->getSupportForUniqueIndex(), - $dbForProject->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getMaxIndexLength(), + $dbForDatabases->getAdapter()->getInternalIndexesKeys(), + $dbForDatabases->getAdapter()->getSupportForIndexArray(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexNull(), + $dbForDatabases->getAdapter()->getSupportForSpatialIndexOrder(), + $dbForDatabases->getAdapter()->getSupportForVectors(), + $dbForDatabases->getAdapter()->getSupportForAttributes(), + $dbForDatabases->getAdapter()->getSupportForMultipleFulltextIndexes(), + $dbForDatabases->getAdapter()->getSupportForIdenticalIndexes(), + $dbForDatabases->getAdapter()->getSupportForObjectIndexes(), + $dbForDatabases->getAdapter()->getSupportForTrigramIndex(), + $dbForDatabases->getAdapter()->getSupportForSpatialAttributes(), + $dbForDatabases->getAdapter()->getSupportForIndex(), + $dbForDatabases->getAdapter()->getSupportForUniqueIndex(), + $dbForDatabases->getAdapter()->getSupportForFulltextIndex(), + $dbForDatabases->getAdapter()->getSupportForTTLIndexes(), + $dbForDatabases->getAdapter()->getSupportForObject() ); if (!$validator->isValid($index)) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php index f34fd82997..5d9d425d71 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Update.php @@ -70,12 +70,13 @@ class Update extends Action ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $databaseId, string $collectionId, ?string $name, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void { $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); if ($database->isEmpty()) { @@ -110,7 +111,8 @@ class Update extends Action ->setAttribute('search', \implode(' ', [$collectionId, $searchName])) ); - $dbForProject->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity); + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $permissions, $documentSecurity); $queueForEvents ->setContext('database', $database) diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php index de20d058c4..37213f1061 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Usage/Get.php @@ -31,6 +31,11 @@ class Get extends Action return UtopiaResponse::MODEL_USAGE_COLLECTION; } + protected function getMetric(): string + { + return METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + } + public function __construct() { $this @@ -64,14 +69,16 @@ class Get extends Action ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('getDatabasesDB') ->callback($this->action(...)); } - public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization): void + public function action(string $databaseId, string $range, string $collectionId, UtopiaResponse $response, Database $dbForProject, Authorization $authorization, callable $getDatabasesDB): void { $database = $dbForProject->getDocument('databases', $databaseId); $collectionDocument = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); - $collection = $dbForProject->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence()); + $dbForDatabases = $getDatabasesDB($database); + $collection = $dbForDatabases->getCollection('database_' . $database->getSequence() . '_collection_' . $collectionDocument->getSequence()); if ($collection->isEmpty()) { throw new Exception($this->getNotFoundException(), params: [$collectionId]); @@ -81,7 +88,7 @@ class Get extends Action $stats = $usage = []; $days = $periods[$range]; $metrics = [ - str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS), + str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collectionDocument->getSequence()], $this->getMetric()), ]; $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php index c2786b9f26..3585bc4477 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Create.php @@ -19,7 +19,9 @@ use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Limit as LimitException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Helpers\ID; +use Utopia\DSN\DSN; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; +use Utopia\System\System; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -30,6 +32,121 @@ class Create extends Action return 'createDatabase'; } + protected function getDatabaseDSN(Document $project): string + { + // TODO: use database worker for for creating the v2 schema if not present + // it is considered that the v2 metadata schema is already created during server start in the http.php + return $this->constructDatabaseDSNFromProjectDatabase($this->getDatabaseType(), $project->getAttribute('region'), $project->getAttribute('database')); + } + + private function constructDatabaseDSNFromProjectDatabase(string $databasetype, $region, ?string $dsn = null): string + { + $databases = []; + $databaseKeys = []; + /** + * @var string|null $databaseOverride + */ + $databaseOverride = ''; + $dbScheme = ''; + $databaseSharedTables = []; + $databaseSharedTablesV1 = []; + $databaseSharedTablesV2 = []; + $projectSharedTables = []; + $projectSharedTablesV1 = []; + $projectSharedTablesV2 = []; + + switch ($databasetype) { + case DOCUMENTSDB: + $databases = Config::getParam('pools-documentsdb', []); + $databaseKeys = System::getEnv('_APP_DATABASE_DOCUMENTSDB_KEYS', ''); + $databaseOverride = System::getEnv('_APP_DATABASE_DOCUMENTSDB_OVERRIDE'); + $dbScheme = System::getEnv('_APP_DB_HOST_DOCUMENTSDB', 'mongodb'); + $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES', '')); + $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_DOCUMENTSDB_SHARED_TABLES_V1', '')); + break; + case VECTORSDB: + $databases = Config::getParam('pools-vectorsdb', []); + $databaseKeys = System::getEnv('_APP_DATABASE_VECTORSDB_KEYS', ''); + $databaseOverride = System::getEnv('_APP_DATABASE_VECTORSDB_OVERRIDE'); + $dbScheme = System::getEnv('_APP_DB_HOST_VECTORSDB', 'postgresql'); + $databaseSharedTables = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES', '')); + $databaseSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_VECTORSDB_SHARED_TABLES_V1', '')); + break; + default: + // legacy/tablesdb + // it is already created during create project + return $dsn; + } + + $isSharedTablesV1 = false; + $isSharedTablesV2 = false; + + if (!empty($dsn)) { + try { + $parsedDsn = new DSN($dsn); + $dsnHost = $parsedDsn->getHost(); + } catch (\InvalidArgumentException) { + $dsnHost = $dsn; + } + + $projectSharedTables = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES', '')); + $projectSharedTablesV1 = \explode(',', System::getEnv('_APP_DATABASE_SHARED_TABLES_V1', '')); + $projectSharedTablesV2 = \array_diff($projectSharedTables, $projectSharedTablesV1); + $isSharedTablesV1 = \in_array($dsnHost, $projectSharedTablesV1); + $isSharedTablesV2 = \in_array($dsnHost, $projectSharedTablesV2); + } + + if ($region !== 'default') { + $keys = explode(',', $databaseKeys); + $databases = array_filter($keys, function ($value) use ($region) { + return str_contains($value, $region); + }); + } + $databaseSharedTablesV2 = \array_diff($databaseSharedTables, $databaseSharedTablesV1); + + $index = \array_search($databaseOverride, $databases); + if ($index !== false) { + $selectedDsn = $databases[$index]; + } else { + if (!empty($dsn)) { + $beforeFilter = \array_values($databases); + if ($isSharedTablesV1) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV1)); + } elseif ($isSharedTablesV2) { + $databases = array_filter($databases, fn ($value) => \in_array($value, $databaseSharedTablesV2)); + } else { + $databases = array_filter($databases, fn ($value) => !\in_array($value, $databaseSharedTables)); + } + } + $selectedDsn = !empty($databases) ? $databases[array_rand($databases)] : ''; + } + + if (\in_array($selectedDsn, $databaseSharedTables)) { + $schema = 'appwrite'; + $database = 'appwrite'; + $namespace = System::getEnv('_APP_DATABASE_SHARED_NAMESPACE', ''); + $selectedDsn = $schema . '://' . $selectedDsn . '?database=' . $database; + + if (!empty($namespace)) { + $selectedDsn .= '&namespace=' . $namespace; + } + } + try { + new DSN($selectedDsn); + } catch (\InvalidArgumentException) { + $selectedDsn = $dbScheme.'://' . $selectedDsn; + } + + return $selectedDsn; + } + + protected function getDatabaseCollection() + { + return match ($this->getDatabaseType()) { + 'vectorsdb' => (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? [], + default => (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? [], + }; + } public function __construct() { $this @@ -65,13 +182,15 @@ class Create extends Action ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->callback($this->action(...)); } - public function action(string $databaseId, string $name, bool $enabled, UtopiaResponse $response, Database $dbForProject, Event $queueForEvents): void + public function action(string $databaseId, string $name, bool $enabled, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents): void { $databaseId = $databaseId == 'unique()' ? ID::unique() : $databaseId; @@ -82,6 +201,7 @@ class Create extends Action 'enabled' => $enabled, 'search' => implode(' ', [$databaseId, $name]), 'type' => $this->getDatabaseType(), + 'database' => $this->getDatabaseDSN($project) ])); } catch (DuplicateException) { throw new Exception(Exception::DATABASE_ALREADY_EXISTS, params: [$databaseId]); @@ -91,7 +211,7 @@ class Create extends Action $database = $dbForProject->getDocument('databases', $databaseId); - $collections = (Config::getParam('collections', [])['databases'] ?? [])['collections'] ?? []; + $collections = $this->getDatabaseCollection(); if (empty($collections)) { throw new Exception(Exception::GENERAL_SERVER_ERROR, 'The "collections" collection is not configured.'); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php index e2a4491736..f3edf010d4 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Action.php @@ -10,11 +10,45 @@ abstract class Action extends DatabasesAction * The current API context (either 'table' or 'collection'). */ private ?string $context = COLLECTIONS; + private ?string $databaseType = LEGACY; + + public function getDatabaseType(): string + { + return $this->databaseType; + } + + protected function getDatabasesOperationWriteMetric(): string + { + if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) { + return METRIC_DATABASES_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASES_OPERATIONS_WRITES; + + } + protected function getDatabasesIdOperationWriteMetric(): string + { + if ($this->databaseType === LEGACY || $this->databaseType === TABLESDB) { + return METRIC_DATABASE_ID_OPERATIONS_WRITES; + } + return $this->databaseType.'.'.METRIC_DATABASE_ID_OPERATIONS_WRITES; + } public function setHttpPath(string $path): DatabasesAction { - if (\str_contains($path, '/tablesdb')) { - $this->context = TABLES; + switch (true) { + case str_contains($path, '/tablesdb'): + $this->context = TABLES; + $this->databaseType = TABLESDB; + break; + + case str_contains($path, '/documentsdb'): + $this->context = COLLECTIONS; + $this->databaseType = DOCUMENTSDB; + break; + case str_contains($path, '/vectorsdb'): + $this->context = COLLECTIONS; + $this->databaseType = VECTORSDB; + break; } return parent::setHttpPath($path); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php index eebb3a77d5..30c9b7cb30 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Operations/Create.php @@ -65,17 +65,18 @@ class Create extends Action ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization): void + public function action(string $transactionId, array $operations, UtopiaResponse $response, Database $dbForProject, TransactionState $transactionState, array $plan, Authorization $authorization, User $user): void { if (empty($operations)) { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Operations array cannot be empty'); } - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); // API keys and admins can read any transaction, regular users need permissions $transaction = ($isAPIKey || $isPrivilegedUser) @@ -148,7 +149,7 @@ class Create extends Action $collectionKey = 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(); $isDependant = isset($dependants[$collectionKey][$documentId]); - $document = $transactionState->getDocument($collectionKey, $documentId, $transactionId); + $document = $transactionState->getDocument($database, $collectionKey, $documentId, $transactionId); if ($document->isEmpty() && !$isDependant && $operation['action'] !== 'upsert') { throw new Exception(Exception::DOCUMENT_NOT_FOUND, params: [$documentId]); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php index 9a5a63ea91..a5d96d5768 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Transactions/Update.php @@ -67,8 +67,10 @@ class Update extends Action ->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject']) ->param('commit', false, new Boolean(), 'Commit transaction?', true) ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('transactionState') ->inject('queueForDeletes') @@ -88,7 +90,8 @@ class Update extends Action * @param bool $rollback * @param UtopiaResponse $response * @param Database $dbForProject - * @param Document $user + * @param callable $getDatabasesDB + * @param User $user * @param TransactionState $transactionState * @param Delete $queueForDeletes * @param Event $queueForEvents @@ -106,7 +109,7 @@ class Update extends Action * @throws Structure * @throws \Utopia\Http\Exception */ - public function action(string $transactionId, bool $commit, bool $rollback, UtopiaResponse $response, Database $dbForProject, Document $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void + public function action(string $transactionId, bool $commit, bool $rollback, Document $project, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, User $user, TransactionState $transactionState, Delete $queueForDeletes, Event $queueForEvents, Context $usage, Event $queueForRealtime, Event $queueForFunctions, Event $queueForWebhooks, Authorization $authorization, EventProcessor $eventProcessor): void { if (!$commit && !$rollback) { throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Either commit or rollback must be true'); @@ -115,8 +118,8 @@ class Update extends Action throw new Exception(Exception::GENERAL_BAD_REQUEST, 'Cannot commit and rollback at the same time'); } - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $transaction = ($isAPIKey || $isPrivilegedUser) ? $authorization->skip(fn () => $dbForProject->getDocument('transactions', $transactionId)) @@ -135,14 +138,52 @@ class Update extends Action } if ($commit) { - $operations = []; $totalOperations = 0; $databaseOperations = []; $currentDocumentId = null; + $firstOperation = $authorization->skip(fn () => $dbForProject->findOne('transactionLogs', [ + Query::equal('transactionInternalId', [$transaction->getSequence()]), + Query::orderAsc(), + ])); + + if ($firstOperation->isEmpty()) { + $transaction = $authorization->skip(fn () => $dbForProject->updateDocument( + 'transactions', + $transactionId, + new Document(['status' => 'committed']) + )); + + $queueForDeletes + ->setType(DELETE_TYPE_DOCUMENT) + ->setDocument($transaction); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($transaction, $this->getResponseModel()); + + return; + } + + $databaseDoc = null; + switch ($this->getDatabaseType()) { + case DATABASE_TYPE_DOCUMENTSDB: + case DATABASE_TYPE_VECTORSDB: + $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [ + Query::equal('$sequence', [$firstOperation['databaseInternalId']]) + ])); + break; + default: + // Legacy/tablesdb: use project-level database + $databaseDoc = new Document(['database' => $project->getAttribute('database')]); + break; + } + + $dbForDatabases = $getDatabasesDB($databaseDoc); + try { - $dbForProject->withTransaction(function () use ($dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $usage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { + $dbForDatabases->withTransaction(function () use ($dbForDatabases, $dbForProject, $transactionState, $queueForDeletes, $transactionId, &$transaction, &$operations, &$totalOperations, &$databaseOperations, &$currentDocumentId, $queueForEvents, $usage, $queueForRealtime, $queueForFunctions, $queueForWebhooks, $authorization) { $authorization->skip(fn () => $dbForProject->updateDocument('transactions', $transactionId, new Document([ 'status' => 'committing', ]))); @@ -182,7 +223,7 @@ class Update extends Action } if ($action === 'delete' && $documentId && empty($data)) { - $doc = $dbForProject->getDocument($collectionId, $documentId); + $doc = $dbForDatabases->getDocument($collectionId, $documentId); if (!$doc->isEmpty()) { $operation['data'] = $doc->getArrayCopy(); $data = $operation['data']; @@ -196,40 +237,40 @@ class Update extends Action switch ($action) { case 'create': - $this->handleCreateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleCreateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'update': - $this->handleUpdateOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleUpdateOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'upsert': - $this->handleUpsertOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleUpsertOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'delete': - $this->handleDeleteOperation($dbForProject, $collectionId, $documentId, $createdAt, $state); + $this->handleDeleteOperation($dbForDatabases, $collectionId, $documentId, $createdAt, $state); break; case 'increment': - $this->handleIncrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleIncrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'decrement': - $this->handleDecrementOperation($dbForProject, $collectionId, $documentId, $data, $createdAt, $state); + $this->handleDecrementOperation($dbForDatabases, $collectionId, $documentId, $data, $createdAt, $state); break; case 'bulkCreate': - $count = $this->handleBulkCreateOperation($dbForProject, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkCreateOperation($dbForDatabases, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkUpdate': - $count = $this->handleBulkUpdateOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkUpdateOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkUpsert': - $count = $this->handleBulkUpsertOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkUpsertOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; case 'bulkDelete': - $count = $this->handleBulkDeleteOperation($dbForProject, $transactionState, $collectionId, $data, $createdAt, $state); + $count = $this->handleBulkDeleteOperation($dbForDatabases, $transactionState, $collectionId, $data, $createdAt, $state); $totalOperations += $count; $databaseOperations[$databaseInternalId] = ($databaseOperations[$databaseInternalId] ?? 0) + $count; break; @@ -279,15 +320,16 @@ class Update extends Action throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); } - $usage->addMetric(METRIC_DATABASES_OPERATIONS_WRITES, $totalOperations); + $usage->addMetric($this->getDatabasesOperationWriteMetric(), $totalOperations); foreach ($databaseOperations as $sequence => $count) { $usage->addMetric( - str_replace('{databaseInternalId}', $sequence, METRIC_DATABASE_ID_OPERATIONS_WRITES), + str_replace('{databaseInternalId}', $sequence, $this->getDatabasesIdOperationWriteMetric()), $count ); } + $dbCache = []; foreach ($operations as $operation) { $databaseInternalId = $operation['databaseInternalId']; $collectionInternalId = $operation['collectionInternalId']; @@ -300,6 +342,16 @@ class Update extends Action $data = $data->getArrayCopy(); } + // using a dbCache so only one time database is set with databaseInternalId + if (!isset($dbCache[$databaseInternalId])) { + $databaseDoc = $authorization->skip(fn () => $dbForProject->findOne('databases', [ + Query::equal('$sequence', [$databaseInternalId]) + ])); + $dbCache[$databaseInternalId] = $getDatabasesDB($databaseDoc); + } + + $dbForDatabases = $dbCache[$databaseInternalId]; + $database = $authorization->skip(fn () => $dbForProject->findOne('databases', [ Query::equal('$sequence', [$databaseInternalId]) ])); @@ -329,7 +381,7 @@ class Update extends Action $eventAction = 'create'; $docId = $documentId ?? $data['$id'] ?? null; if ($docId) { - $doc = $dbForProject->getDocument($collectionId, $docId); + $doc = $dbForDatabases->getDocument($collectionId, $docId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } @@ -340,7 +392,7 @@ class Update extends Action case 'decrement': $eventAction = 'update'; if ($documentId) { - $doc = $dbForProject->getDocument($collectionId, $documentId); + $doc = $dbForDatabases->getDocument($collectionId, $documentId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } @@ -356,7 +408,7 @@ class Update extends Action $eventAction = 'update'; $docId = $documentId ?? $data['$id'] ?? null; if ($docId) { - $doc = $dbForProject->getDocument($collectionId, $docId); + $doc = $dbForDatabases->getDocument($collectionId, $docId); if (!$doc->isEmpty()) { $documentsToTrigger[] = $doc; } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php index 6f90e77e2b..18e6fd7a8b 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/Get.php @@ -26,6 +26,43 @@ class Get extends Action return 'getDatabaseUsage'; } + protected $databaseType = DATABASE_TYPE_LEGACY; + + public function setHttpPath(string $path): Action + { + $this->databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => DATABASE_TYPE_LEGACY, + }; + + return parent::setHttpPath($path); + } + + protected function getMetrics(): array + { + $metrics = [ + METRIC_DATABASE_ID_COLLECTIONS, + METRIC_DATABASE_ID_DOCUMENTS, + METRIC_DATABASE_ID_STORAGE, + METRIC_DATABASE_ID_OPERATIONS_READS, + METRIC_DATABASE_ID_OPERATIONS_WRITES + ]; + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return $metrics; + } + + return array_map( + fn ($metric) => "{$this->databaseType}.{$metric}", + $metrics + ); + } + + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_USAGE_DATABASE; + } + public function __construct() { $this @@ -74,13 +111,10 @@ class Get extends Action $periods = Config::getParam('usage', []); $stats = $usage = []; $days = $periods[$range]; - $metrics = [ - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_COLLECTIONS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_DOCUMENTS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_STORAGE), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_READS), - str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_OPERATIONS_WRITES) - ]; + $metrics = array_map( + fn ($metric) => str_replace('{databaseInternalId}', $database->getSequence(), $metric), + $this->getMetrics() + ); $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { @@ -142,6 +176,6 @@ class Get extends Action 'storage' => $usage[$metrics[2]]['data'], 'databaseReads' => $usage[$metrics[3]]['data'], 'databaseWrites' => $usage[$metrics[4]]['data'], - ]), UtopiaResponse::MODEL_USAGE_DATABASE); + ]), $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php index db5ad21358..b8cb774a3e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Usage/XList.php @@ -24,6 +24,43 @@ class XList extends Action return 'listDatabaseUsage'; } + protected $databaseType = DATABASE_TYPE_LEGACY; + + public function setHttpPath(string $path): Action + { + $this->databaseType = match (true) { + str_contains($path, '/documentsdb') => DATABASE_TYPE_DOCUMENTSDB, + str_contains($path, '/vectorsdb') => DATABASE_TYPE_VECTORSDB, + default => DATABASE_TYPE_LEGACY, + }; + + return parent::setHttpPath($path); + } + + protected function getMetrics(): array + { + $metrics = [ + METRIC_DATABASES, + METRIC_COLLECTIONS, + METRIC_DOCUMENTS, + METRIC_DATABASES_STORAGE, + METRIC_DATABASES_OPERATIONS_READS, + METRIC_DATABASES_OPERATIONS_WRITES, + ]; + if ($this->databaseType === DATABASE_TYPE_LEGACY || $this->databaseType === DATABASE_TYPE_TABLESDB) { + return $metrics; + } + return array_map( + fn ($metric) => "{$this->databaseType}.{$metric}", + $metrics + ); + } + + protected function getResponseModel(): string + { + return UtopiaResponse::MODEL_USAGE_DATABASES; + } + public function __construct() { $this @@ -66,14 +103,7 @@ class XList extends Action $periods = Config::getParam('usage', []); $stats = $usage = []; $days = $periods[$range]; - $metrics = [ - METRIC_DATABASES, - METRIC_COLLECTIONS, - METRIC_DOCUMENTS, - METRIC_DATABASES_STORAGE, - METRIC_DATABASES_OPERATIONS_READS, - METRIC_DATABASES_OPERATIONS_WRITES, - ]; + $metrics = $this->getMetrics(); $authorization->skip(function () use ($dbForProject, $days, $metrics, &$stats) { foreach ($metrics as $metric) { @@ -136,6 +166,6 @@ class XList extends Action 'storage' => $usage[$metrics[3]]['data'], 'databasesReads' => $usage[$metrics[4]]['data'], 'databasesWrites' => $usage[$metrics[5]]['data'], - ]), UtopiaResponse::MODEL_USAGE_DATABASES); + ]), $this->getResponseModel()); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php index 8627fa49c5..21dbc83edc 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/XList.php @@ -17,7 +17,6 @@ use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Query; use Utopia\Database\Validator\Query\Cursor; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; -use Utopia\Platform\Action; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -28,6 +27,11 @@ class XList extends Action return 'listDatabases'; } + protected function getDatabaseTypeQueryFilters(): array + { + return [Query::equal('type', [$this->getDatabaseType()])]; + } + public function __construct() { $this @@ -93,6 +97,7 @@ class XList extends Action } try { + $queries = array_merge($queries, $this->getDatabaseTypeQueryFilters()); $databases = $dbForProject->find('databases', $queries); $total = $includeTotal ? $dbForProject->count('databases', $queries, APP_LIMIT_COUNT) : 0; } catch (OrderException $e) { diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php new file mode 100644 index 0000000000..d1e91addf7 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Create.php @@ -0,0 +1,75 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections') + ->desc('Create collection') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'collections.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'createCollection', + description: '/docs/references/documentsdb/create-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Collection name. Max length: 128 chars.') + ->param('permissions', null, new Nullable(new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE)), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->param('attributes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attribute definitions to create. Each attribute should contain: key (string), type (string: string, integer, float, boolean, datetime, relationship), size (integer, required for string type), required (boolean, optional), default (mixed, optional), array (boolean, optional), and type-specific options.', true) + ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of attribute keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php new file mode 100644 index 0000000000..d698b40203 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Delete.php @@ -0,0 +1,62 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Delete collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].delete') + ->label('audits.event', 'collection.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'deleteCollection', + description: '/docs/references/documentsdb/delete-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php new file mode 100644 index 0000000000..6d986fc6b1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Decrement.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/decrement') + ->desc('Decrement document attribute') + ->groups(['api', 'database']) + ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'decrementDocumentAttribute', + description: '/docs/references/documentsdb/decrement-document-attribute.md', + auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') + ->param('value', 1, new Numeric(), 'Value to decrement the attribute by. The value must be a number.', true) + ->param('min', null, new Numeric(), 'Minimum value for the attribute. If the current value is lesser than this value, an exception will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php new file mode 100644 index 0000000000..09def76941 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Attribute/Increment.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/:attribute/increment') + ->desc('Increment document attribute') + ->groups(['api', 'database']) + ->label('event', 'documentsdb.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'incrementDocumentAttribute', + description: '/docs/references/documentsdb/increment-document-attribute.md', + auth: [AuthType::SESSION, AuthType::JWT, AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('attribute', '', new Key(), 'Attribute key.') + ->param('value', 1, new Numeric(), 'Value to increment the attribute by. The value must be a number.', true) + ->param('max', null, new Numeric(), 'Maximum value for the attribute. If the current value is greater than this value, an error will be thrown.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php new file mode 100644 index 0000000000..09ad9a5741 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Delete.php @@ -0,0 +1,72 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Delete documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteDocuments', + description: '/docs/references/documentsdb/delete-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php new file mode 100644 index 0000000000..c723f1bc30 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Update documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'updateDocuments', + description: '/docs/references/documentsdb/update-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php new file mode 100644 index 0000000000..d5b62ec903 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Bulk/Upsert.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Upsert documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'upsertDocuments', + description: '/docs/references/documentsdb/upsert-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} 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 new file mode 100644 index 0000000000..039a05ff50 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Create.php @@ -0,0 +1,116 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('Create document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createDocument', + desc: 'Create document', + description: '/docs/references/documentsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + 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 Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createDocuments', + desc: 'Create documents', + description: '/docs/references/documentsdb/create-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') + ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"username":"walter.obrien","email":"walter.obrien@example.com","fullName":"Walter O\'Brien","age":30,"isAdmin":false}') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('queueForEvents') + ->inject('usage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php new file mode 100644 index 0000000000..0253c287aa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Delete.php @@ -0,0 +1,77 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Delete document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') + ->label('audits.event', 'document.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteDocument', + description: '/docs/references/documentsdb/delete-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php new file mode 100644 index 0000000000..47d352bf98 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Get.php @@ -0,0 +1,65 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Get document') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'getDocument', + description: '/docs/references/documentsdb/get-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('transactionState') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php new file mode 100644 index 0000000000..cc7fe41555 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') + ->desc('List document logs') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'logs', + name: 'listDocumentLogs', + description: '/docs/references/documentsdb/get-document-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php new file mode 100644 index 0000000000..9e79bb5464 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Update.php @@ -0,0 +1,76 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Update document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'updateDocument', + description: '/docs/references/documentsdb/update-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php new file mode 100644 index 0000000000..448c2d44bc --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/Upsert.php @@ -0,0 +1,78 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Upsert a document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.upsert') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'upsertDocument', + description: '/docs/references/documentsdb/upsert-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php new file mode 100644 index 0000000000..9e0d0b10d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Documents/XList.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/documents') + ->desc('List documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listDocuments', + description: '/docs/references/documentsdb/list-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php new file mode 100644 index 0000000000..53120dd636 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Get.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Get collection') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'getCollection', + description: '/docs/references/documentsdb/get-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php new file mode 100644 index 0000000000..dc3ce34605 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Create.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes') + ->desc('Create index') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'index.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'createIndex', + description: '/docs/references/documentsdb/create-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_ACCEPTED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).', false, ['dbForProject']) + ->param('key', null, fn (Database $dbForProject) => new Key(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Index Key.', false, ['dbForProject']) + ->param('type', null, new WhiteList([Database::INDEX_KEY, Database::INDEX_FULLTEXT, Database::INDEX_UNIQUE]), 'Index type.') + ->param('attributes', null, fn (Database $dbForProject) => new ArrayList(new Key(true, $dbForProject->getAdapter()->getMaxUIDLength()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.', false, ['dbForProject']) + ->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true) + ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php new file mode 100644 index 0000000000..d4464f171d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Delete.php @@ -0,0 +1,67 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Delete index') + ->groups(['api', 'database']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update') + ->label('audits.event', 'index.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/delete-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', '', new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php new file mode 100644 index 0000000000..7fa75b6ed9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/Get.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Get index') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/get-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php new file mode 100644 index 0000000000..1e16155f76 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Indexes/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/indexes') + ->desc('List indexes') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/documentsdb/list-indexes.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new Indexes(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Indexes::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php new file mode 100644 index 0000000000..51695ea165 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Logs/XList.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/logs') + ->desc('List collection logs') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: $this->getSdkGroup(), + name: 'listCollectionLogs', + description: '/docs/references/documentsdb/get-collection-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php new file mode 100644 index 0000000000..052970fec4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Update.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId') + ->desc('Update collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].update') + ->label('audits.event', 'collection.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'updateCollection', + description: '/docs/references/documentsdb/update-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php new file mode 100644 index 0000000000..51dd3c381d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/Usage/Get.php @@ -0,0 +1,65 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections/:collectionId/usage') + ->desc('Get collection usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: null, + name: 'getCollectionUsage', + description: '/docs/references/documentsdb/get-collection-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->param('collectionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Collection ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php new file mode 100644 index 0000000000..638244145b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Collections/XList.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/collections') + ->desc('List collections') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'collections', + name: 'listCollections', + description: '/docs/references/documentsdb/list-collections.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Collections(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Collections::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php new file mode 100644 index 0000000000..f9b425b3e6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Create.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb') + ->desc('Create database') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].create') + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'database.create') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'create', + description: '/docs/references/documentsdb/create.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php new file mode 100644 index 0000000000..1708656c98 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Delete.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Delete database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].delete') + ->label('audits.event', 'database.delete') + ->label('audits.resource', 'database/{request.databaseId}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'delete', + description: '/docs/references/documentsdb/delete.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('usage') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php new file mode 100644 index 0000000000..309a3b867e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Get.php @@ -0,0 +1,50 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Get database') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'get', + description: '/docs/references/documentsdb/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php new file mode 100644 index 0000000000..8afb0fd1ef --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Logs/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/logs') + ->desc('List database logs') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: 'logs', + name: 'listDatabaseLogs', + description: '/docs/references/documentsdb/get-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_LOG_LIST, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php new file mode 100644 index 0000000000..9341779dcd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Create.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/transactions') + ->desc('Create transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'createTransaction', + description: '/docs/references/documentsdb/create-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php new file mode 100644 index 0000000000..036f2e9600 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Delete transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'deleteTransaction', + description: '/docs/references/documentsdb/delete-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php new file mode 100644 index 0000000000..7def4f0b9a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Get.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Get transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'getTransaction', + description: '/docs/references/documentsdb/get-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php new file mode 100644 index 0000000000..963af2f43e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Operations/Create.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId/operations') + ->desc('Create operations') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'createOperations', + description: '/docs/references/documentsdb/create-operations.md', + auth: [AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php new file mode 100644 index 0000000000..b4c0c2ffab --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/Update.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/documentsdb/transactions/:transactionId') + ->desc('Update transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'updateTransaction', + description: '/docs/references/documentsdb/update-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('commit', false, new Boolean(), 'Commit transaction?', true) + ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('transactionState') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('usage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php new file mode 100644 index 0000000000..b216ce6a4a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Transactions/XList.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/transactions') + ->desc('List transactions') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'transactions', + name: 'listTransactions', + description: '/docs/references/documentsdb/list-transactions.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php new file mode 100644 index 0000000000..4bf5747b54 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Update.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/documentsdb/:databaseId') + ->desc('Update database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].update') + ->label('audits.event', 'database.update') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'update', + description: '/docs/references/documentsdb/update.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php new file mode 100644 index 0000000000..8373b6bc20 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/:databaseId/usage') + ->desc('Get DocumentsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: null, + name: 'getUsage', + description: '/docs/references/documentsdb/get-database-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_DOCUMENTSDB, + ) + ], + contentType: ContentType::JSON, + ), + ]) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php new file mode 100644 index 0000000000..16535765ca --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/Usage/XList.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb/usage') + ->desc('Get DocumentsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'documentsDB', + group: null, + name: 'listUsage', + description: '/docs/references/documentsdb/list-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_DATABASES, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php new file mode 100644 index 0000000000..13814b37e2 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/DocumentsDB/XList.php @@ -0,0 +1,53 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/documentsdb') + ->desc('List databases') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'documentsDB', + group: 'documentsdb', + name: 'list', + description: '/docs/references/documentsdb/list.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Databases(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following columns: ' . implode(', ', Databases::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php index 8aa6e1e28b..eb2293dc28 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Create.php @@ -50,8 +50,10 @@ class Create extends DatabaseCreate ->param('databaseId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->callback($this->action(...)); } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php index 9d32166a26..48f1136b09 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Create.php @@ -67,6 +67,7 @@ class Create extends CollectionCreate ->param('indexes', [], new ArrayList(new JSON(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index definitions to create. Each index should contain: key (string), type (string: key, fulltext, unique, spatial), attributes (array of column keys), orders (array of ASC/DESC, optional), and lengths (array of integers, optional).', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php index aa5b94c00f..97c5465fe3 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Delete.php @@ -54,6 +54,7 @@ class Delete extends CollectionDelete ->param('tableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Table ID.', false, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php index 8186e07d61..e683aafba1 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Indexes/Create.php @@ -64,6 +64,7 @@ class Create extends IndexCreate ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForDatabase') ->inject('queueForEvents') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php index adaf83ccf1..37a3db01db 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Delete.php @@ -61,6 +61,7 @@ class Delete extends DocumentsDelete ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php index d706d1f28b..bb839b752e 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Update.php @@ -63,6 +63,7 @@ class Update extends DocumentsUpdate ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php index 58da5064f9..364bf4a928 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Bulk/Upsert.php @@ -63,6 +63,7 @@ class Upsert extends DocumentsUpsert ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('queueForEvents') ->inject('queueForRealtime') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php index e1e717e9b1..ea1bfa163d 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Decrement.php @@ -65,10 +65,12 @@ class Decrement extends DecrementDocumentAttribute ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php index 0b20450254..2f8be876d7 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Column/Increment.php @@ -65,10 +65,12 @@ class Increment extends IncrementDocumentAttribute ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php index fde8005d2b..26649accfb 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Create.php @@ -104,6 +104,7 @@ class Create extends DocumentCreate ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID for staging the operation.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('queueForEvents') ->inject('usage') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php index 1845edc307..06aee2cb30 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Delete.php @@ -67,11 +67,13 @@ class Delete extends DocumentDelete ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php index 43b799e5b1..65ae238a1a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Get.php @@ -57,9 +57,11 @@ class Get extends DocumentGet ->param('transactionId', null, fn (Database $dbForProject) => new Nullable(new UID($dbForProject->getAdapter()->getMaxUIDLength())), 'Transaction ID to read uncommitted changes within the transaction.', true, ['dbForProject']) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('usage') ->inject('transactionState') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php index a5f4787b05..e1d821130f 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Logs/XList.php @@ -50,6 +50,7 @@ class XList extends DocumentLogXList ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('locale') ->inject('geodb') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php index c0d90f9531..93ec5d3b58 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Update.php @@ -65,11 +65,13 @@ class Update extends DocumentUpdate ->inject('requestTimestamp') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php index 7f0aa0ad7d..472a49cf64 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/Upsert.php @@ -68,6 +68,7 @@ class Upsert extends DocumentUpsert ->inject('response') ->inject('user') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('usage') ->inject('transactionState') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php index 6e5dcd9370..ca83b10aae 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Rows/XList.php @@ -61,6 +61,7 @@ class XList extends DocumentXList ->inject('response') ->inject('dbForProject') ->inject('user') + ->inject('getDatabasesDB') ->inject('usage') ->inject('transactionState') ->inject('authorization') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php index c525f97715..88b16d57f0 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Update.php @@ -62,6 +62,7 @@ class Update extends CollectionUpdate ->param('enabled', true, new Boolean(), 'Is table enabled? When set to \'disabled\', users cannot access the table but Server SDKs with and API key can still read and write to the table. No data is lost when this is toggled.', true) ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php index 4261ceaab6..6976be014c 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Tables/Usage/Get.php @@ -54,6 +54,7 @@ class Get extends CollectionUsageGet ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('getDatabasesDB') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php index 818ed70cea..b00b75f270 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Operations/Create.php @@ -56,6 +56,7 @@ class Create extends OperationsCreate ->inject('transactionState') ->inject('plan') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } } diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php index 68ea2b8901..872927d533 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/Transactions/Update.php @@ -51,8 +51,10 @@ class Update extends TransactionsUpdate ->param('transactionId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Transaction ID.', false, ['dbForProject']) ->param('commit', false, new Boolean(), 'Commit transaction?', true) ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') ->inject('response') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('user') ->inject('transactionState') ->inject('queueForDeletes') diff --git a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php index 80a9bd3686..8dc0f6521a 100644 --- a/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php +++ b/src/Appwrite/Platform/Modules/Databases/Http/TablesDB/XList.php @@ -9,6 +9,7 @@ use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Database\Validator\Queries\Databases; use Appwrite\Utopia\Response as UtopiaResponse; +use Utopia\Database\Query; use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\Boolean; use Utopia\Validator\Text; @@ -20,6 +21,16 @@ class XList extends DatabaseXList return 'listTablesDatabases'; } + protected function getDatabaseTypeQueryFilters(): array + { + return [ + Query::or([ + Query::equal('type', [DATABASE_TYPE_TABLESDB, DATABASE_TYPE_LEGACY]), + Query::isNull('type'), + ]), + ]; + } + public function __construct() { $this diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php new file mode 100644 index 0000000000..b85a8b30b4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Create.php @@ -0,0 +1,208 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections') + ->desc('Create collection') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'collection.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'createCollection', + description: '/docs/references/vectorsdb/create-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Database ID.', false, ['dbForProject']) + ->param('collectionId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('name', '', new Text(128), 'Collection name. Max length: 128 chars.') + ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimension.') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permissions strings. By default, no user is granted with any permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action(string $databaseId, string $collectionId, string $name, int $dimension, ?array $permissions, bool $documentSecurity, bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + { + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collectionId = $collectionId === 'unique()' ? ID::unique() : $collectionId; + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions) ?? []; + + try { + $collection = $dbForProject->createDocument('database_' . $database->getSequence(), new Document([ + '$id' => $collectionId, + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + '$permissions' => $permissions, + 'documentSecurity' => $documentSecurity, + 'enabled' => $enabled, + 'name' => $name, + 'dimension' => $dimension, + 'search' => \implode(' ', [$collectionId, $name]), + ])); + + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (LimitException) { + throw new Exception($this->getLimitException()); + } catch (NotFoundException) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + /** @var Database $dbForDatabases */ + $dbForDatabases = $getDatabasesDB($database); + + $attributes = []; + $indexes = []; + $collections = (Config::getParam('collections', [])['vectorsdb'] ?? [])['collections'] ?? []; + foreach ($collections['defaultAttributes'] as $attribute) { + if ($attribute['$id'] === 'embeddings') { + $attribute['size'] = $dimension; + } + $attributes[] = new Document($attribute); + } + foreach ($collections['defaultIndexes'] as $index) { + $indexes[] = new Document($index); + } + try { + // passing null in creates only creates the metadata collection + if (!$dbForDatabases->exists(null, Database::METADATA)) { + $dbForDatabases->create(); + } + $dbForDatabases->createCollection( + id: 'database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), + permissions: $permissions, + documentSecurity: $documentSecurity, + attributes:$attributes, + indexes:$indexes + ); + // Create attribute and indexes metadata documents in the attributes and indexes collections + // needed for the get and list calls + $attributeDocs = array_map(function ($attributeConfig) use ($database, $collection, $databaseId, $collectionId, $dimension) { + $key = \is_string($attributeConfig['$id']) ? $attributeConfig['$id'] : (string) $attributeConfig['$id']; + return new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $attributeConfig['type'], + 'status' => 'available', + 'size' => $dimension, + 'required' => $attributeConfig['required'] ?? false, + 'signed' => $attributeConfig['signed'] ?? false, + 'default' => $attributeConfig['default'] ?? null, + 'array' => $attributeConfig['array'] ?? false, + 'format' => $attributeConfig['format'] ?? '', + 'formatOptions' => $attributeConfig['formatOptions'] ?? [], + 'filters' => $attributeConfig['filters'] ?? [], + 'options' => $attributeConfig['options'] ?? [], + ]); + }, $collections['defaultAttributes']); + $dbForProject->createDocuments('attributes', $attributeDocs); + + $indexDocs = array_map(function ($indexConfig) use ($database, $collection, $databaseId, $collectionId) { + $key = \is_string($indexConfig['$id']) ? $indexConfig['$id'] : (string) $indexConfig['$id']; + + return new Document([ + '$id' => ID::custom($database->getSequence() . '_' . $collection->getSequence() . '_' . $key), + 'key' => $key, + 'status' => 'available', + 'databaseInternalId' => $database->getSequence(), + 'databaseId' => $databaseId, + 'collectionInternalId' => $collection->getSequence(), + 'collectionId' => $collectionId, + 'type' => $indexConfig['type'], + 'attributes' => $indexConfig['attributes'] ?? [], + 'lengths' => $indexConfig['lengths'] ?? [], + 'orders' => $indexConfig['orders'] ?? [], + ]); + }, $collections['defaultIndexes']); + + if (!empty($indexDocs)) { + $dbForProject->createDocuments('indexes', $indexDocs); + } + } catch (DuplicateException) { + throw new Exception($this->getDuplicateException()); + } catch (IndexException) { + throw new Exception($this->getInvalidIndexException()); + } catch (LimitException) { + throw new Exception($this->getLimitException()); + } + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam($this->getEventsParamKey(), $collection->getId()); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_CREATED) + ->dynamic($collection, $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php new file mode 100644 index 0000000000..f1188868aa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Delete.php @@ -0,0 +1,62 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Delete collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].delete') + ->label('audits.event', 'collection.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'deleteCollection', + description: '/docs/references/vectorsdb/delete-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php new file mode 100644 index 0000000000..a4d640b423 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Delete.php @@ -0,0 +1,72 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Delete documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteDocuments', + description: '/docs/references/vectorsdb/delete-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php new file mode 100644 index 0000000000..2784fa220a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Update.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Update documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'documents.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'updateDocuments', + description: '/docs/references/vectorsdb/update-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only attribute and value pairs to be updated.', true) + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php new file mode 100644 index 0000000000..cfbf6c9158 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Bulk/Upsert.php @@ -0,0 +1,74 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Upsert documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'upsertDocuments', + description: '/docs/references/vectorsdb/upsert-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of document data as JSON objects. May contain partial documents.', false, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('queueForEvents') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} 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 new file mode 100644 index 0000000000..563b5f60ef --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Create.php @@ -0,0 +1,116 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('Create document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createDocument', + desc: 'Create document', + description: '/docs/references/vectorsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + 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 Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createDocuments', + desc: 'Create documents', + description: '/docs/references/vectorsdb/create-documents.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('databaseId', optional: false), + new Parameter('collectionId', optional: false), + new Parameter('documents', optional: false), + ] + ) + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('documentId', '', new CustomId(), 'Document ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', true) + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection). Make sure to define attributes before creating documents.') + ->param('data', [], new JSON(), 'Document data as JSON object.', true, example: '{"embeddings": [0.12, -0.55, 0.88, 1.02], "metadata": {"key":"value"} }') + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, only the current user is granted all permissions. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documents', [], fn (array $plan) => new ArrayList(new JSON(), $plan['databasesBatchSize'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of documents data as JSON objects.', true, ['plan']) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('queueForEvents') + ->inject('usage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('plan') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php new file mode 100644 index 0000000000..e81e34e1e5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Delete.php @@ -0,0 +1,77 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Delete document') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].delete') + ->label('audits.event', 'document.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{request.documentId}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteDocument', + description: '/docs/references/vectorsdb/delete-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php new file mode 100644 index 0000000000..73f7a55026 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Get.php @@ -0,0 +1,65 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Get document') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'getDocument', + description: '/docs/references/vectorsdb/get-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('transactionState') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php new file mode 100644 index 0000000000..dea9d30119 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId/logs') + ->desc('List document logs') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'logs', + name: 'listDocumentLogs', + description: '/docs/references/vectorsdb/get-document-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php new file mode 100644 index 0000000000..8a8b9d89ae --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Update.php @@ -0,0 +1,76 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Update document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].update') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'updateDocument', + description: '/docs/references/vectorsdb/update-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', new UID(), 'Document ID.') + ->param('data', [], new JSON(), 'Document data as JSON object. Include only fields and value pairs to be updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php new file mode 100644 index 0000000000..f8f17d33d9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/Upsert.php @@ -0,0 +1,79 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents/:documentId') + ->desc('Upsert a document') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].collections.[collectionId].documents.[documentId].upsert') + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'document.upsert') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}/document/{response.$id}') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'upsertDocument', + description: '/docs/references/vectorsdb/upsert-document.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('documentId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Document ID.', false, ['dbForProject']) + ->param('data', [], new JSON(), 'Document data as JSON object. Include all required fields of the document to be created or updated.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE, [Database::PERMISSION_READ, Database::PERMISSION_UPDATE, Database::PERMISSION_DELETE, Database::PERMISSION_WRITE]), 'An array of permissions strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('transactionId', null, new UID(), 'Transaction ID for staging the operation.', true) + ->inject('requestTimestamp') + ->inject('response') + ->inject('user') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('usage') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php new file mode 100644 index 0000000000..c9ed05ac02 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Documents/XList.php @@ -0,0 +1,68 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/documents') + ->desc('List documents') + ->groups(['api', 'database']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listDocuments', + description: '/docs/references/vectorsdb/list-documents.md', + auth: [AuthType::ADMIN, AuthType::SESSION, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) + ->param('transactionId', null, new UID(), 'Transaction ID to read uncommitted changes within the transaction.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->param('ttl', 0, new Range(min: 0, max: 86400), 'TTL (seconds) for cached responses when caching is enabled for select queries. Must be between 0 and 86400 (24 hours).', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('getDatabasesDB') + ->inject('usage') + ->inject('transactionState') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php new file mode 100644 index 0000000000..9619bb5048 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Get.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Get collection') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'getCollection', + description: '/docs/references/vectorsdb/get-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php new file mode 100644 index 0000000000..a535dd5724 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Create.php @@ -0,0 +1,73 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes') + ->desc('Create index') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].tables.[tableId].indexes.[indexId].create') + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'index.create') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.tableId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createIndex', + description: '/docs/references/vectorsdb/create-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_ACCEPTED, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->param('type', null, new WhiteList([Database::INDEX_HNSW_EUCLIDEAN,Database::INDEX_HNSW_DOT, Database::INDEX_HNSW_COSINE, Database::INDEX_OBJECT, Database::INDEX_KEY, Database::INDEX_UNIQUE]), 'Index type.') + ->param('attributes', null, new ArrayList(new Key(true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of attributes to index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' attributes are allowed, each 32 characters long.') + ->param('orders', [], new ArrayList(new WhiteList(['ASC', 'DESC'], false, Database::VAR_STRING), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Array of index orders. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' orders are allowed.', true) + ->param('lengths', [], new ArrayList(new Nullable(new Integer()), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Length of index. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE, optional: true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php new file mode 100644 index 0000000000..5c7fc47ee0 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Delete.php @@ -0,0 +1,67 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Delete index') + ->groups(['api', 'database']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].indexes.[indexId].update') + ->label('audits.event', 'index.delete') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'deleteIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/delete-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', '', new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php new file mode 100644 index 0000000000..4cf646acba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/Get.php @@ -0,0 +1,58 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes/:key') + ->desc('Get index') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'getIndex', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/get-index.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('key', null, new Key(), 'Index Key.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php new file mode 100644 index 0000000000..acc46fb570 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Indexes/XList.php @@ -0,0 +1,60 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/indexes') + ->desc('List indexes') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listIndexes', // getName needs to be different from parent action to avoid conflict in path name + description: '/docs/references/vectorsdb/list-indexes.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID. You can create a new collection using the Database service [server integration](https://appwrite.io/docs/server/databases#databasesCreateCollection).') + ->param('queries', [], new Indexes(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Indexes::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php new file mode 100644 index 0000000000..cd0e45eb47 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Logs/XList.php @@ -0,0 +1,57 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/logs') + ->desc('List collection logs') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'listCollectionLogs', + description: '/docs/references/vectorsdb/get-collection-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php new file mode 100644 index 0000000000..f8ba767e7e --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Update.php @@ -0,0 +1,117 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId') + ->desc('Update collection') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'collections.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].collections.[collectionId].update') + ->label('audits.event', 'collection.update') + ->label('audits.resource', 'database/{request.databaseId}/collection/{request.collectionId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'updateCollection', + description: '/docs/references/vectorsdb/update-collection.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_VECTORSDB_COLLECTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('collectionId', '', new UID(), 'Collection ID.') + ->param('name', null, new Text(128), 'Collection name. Max length: 128 chars.') + ->param('dimension', null, new Range(MIN_VECTOR_DIMENSION, MAX_VECTOR_DIMENSION), 'Embedding dimensions.', true) + ->param('permissions', null, new Permissions(APP_LIMIT_ARRAY_PARAMS_SIZE), 'An array of permission strings. By default, the current permissions are inherited. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('documentSecurity', false, new Boolean(true), 'Enables configuring permissions for individual documents. A user needs one of document or collection level permissions to access a document. [Learn more about permissions](https://appwrite.io/docs/permissions).', true) + ->param('enabled', true, new Boolean(), 'Is collection enabled? When set to \'disabled\', users cannot access the collection but Server SDKs with and API key can still read and write to the collection. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->inject('authorization') + ->callback($this->action(...)); + } + + public function action(string $databaseId, string $collectionId, ?string $name, ?int $dimensions, ?array $permissions, bool $documentSecurity, ?bool $enabled, UtopiaResponse $response, Database $dbForProject, callable $getDatabasesDB, Event $queueForEvents, Authorization $authorization): void + { + $database = $authorization->skip(fn () => $dbForProject->getDocument('databases', $databaseId)); + if ($database->isEmpty()) { + throw new Exception(Exception::DATABASE_NOT_FOUND); + } + + $collection = $dbForProject->getDocument('database_' . $database->getSequence(), $collectionId); + if ($collection->isEmpty()) { + throw new Exception($this->getNotFoundException()); + } + + $permissions ??= $collection->getPermissions(); + + // Map aggregate permissions into the multiple permissions they represent. + $permissions = Permission::aggregate($permissions); + + $enabled ??= $collection->getAttribute('enabled', true); + + $updated = $dbForProject->updateDocument( + 'database_' . $database->getSequence(), + $collectionId, + $collection + ->setAttribute('name', $name ?? $collection->getAttribute('name')) + ->setAttribute('dimension', $dimensions ?? $collection->getAttribute('dimension')) + ->setAttribute('$permissions', $permissions) + ->setAttribute('documentSecurity', $documentSecurity) + ->setAttribute('enabled', $enabled) + ->setAttribute('search', \implode(' ', [$collectionId, $name ?? $collection->getAttribute('name')])) + ); + + $dbForDatabases = $getDatabasesDB($database); + $dbForDatabases->updateCollection('database_' . $database->getSequence() . '_collection_' . $updated->getSequence(), $permissions, $documentSecurity); + + $queueForEvents + ->setContext('database', $database) + ->setParam('databaseId', $databaseId) + ->setParam($this->getEventsParamKey(), $updated->getId()); + + $response->dynamic($updated, $this->getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php new file mode 100644 index 0000000000..7e0f79a9f1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/Usage/Get.php @@ -0,0 +1,64 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections/:collectionId/usage') + ->desc('Get collection usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: null, + name: 'getCollectionUsage', + description: '/docs/references/vectorsdb/get-collection-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON, + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->param('collectionId', '', new UID(), 'Collection ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->inject('getDatabasesDB') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php new file mode 100644 index 0000000000..7ba26b8b6a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Collections/XList.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/collections') + ->desc('List collections') + ->groups(['api', 'database']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'collections', + name: 'listCollections', + description: '/docs/references/vectorsdb/list-collections.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getResponseModel(), + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Collections(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Collections::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php new file mode 100644 index 0000000000..cc2914fc10 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Create.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb') + ->desc('Create database') + ->groups(['api', 'database']) + ->label('event', 'databases.[databaseId].create') + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('audits.event', 'database.create') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'create', + description: '/docs/references/vectorsdb/create.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') + ->param('name', '', new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is the database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php new file mode 100644 index 0000000000..c9d36904a9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Delete database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].delete') + ->label('audits.event', 'database.delete') + ->label('audits.resource', 'database/{request.databaseId}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'delete', + description: '/docs/references/vectorsdb/delete.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDatabase') + ->inject('queueForEvents') + ->inject('usage') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php new file mode 100644 index 0000000000..d9b378774b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Embeddings/Text/Create.php @@ -0,0 +1,152 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/embeddings/text') + ->desc('Create Text Embeddings') + ->groups(['api', 'database']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_EMBEDDINGS_TEXT) + ->label('audits.event', 'embedding.create') + ->label('audits.resource', 'vectorsdb/embeddings/text') + ->label('abuse-key', 'ip:{ip},method:{method},url:{url},userId:{userId}') + ->label('abuse-limit', APP_LIMIT_WRITE_RATE_DEFAULT * 2) + ->label('abuse-time', APP_LIMIT_WRITE_RATE_PERIOD_DEFAULT) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: $this->getSdkGroup(), + name: 'createTextEmbeddings', + desc: 'Create Text Embedding', + description: '/docs/references/vectorsdb/create-document.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: $this->getBulkResponseModel(), + ) + ], + contentType: ContentType::JSON, + parameters: [ + new Parameter('texts', optional: false), + new Parameter('model', optional: true), + ] + ) + ]) + ->param('texts', [], fn (array $plan) => new ArrayList(new Text(0), $plan['databasesMaxEmbeddingTexts'] ?? APP_LIMIT_DATABASE_BATCH), 'Array of text to generate embeddings.', false, ['plan']) + ->param('model', Ollama::MODEL_EMBEDDING_GEMMA, new WhiteList(Ollama::MODELS), 'The embedding model to use for generating vector embeddings.', true) + ->inject('response') + ->inject('project') + ->inject('embeddingAgent') + ->inject('usage') + ->inject('log') + ->inject('logger') + ->callback($this->action(...)); + } + + public function action(array $texts, string $model, UtopiaResponse $response, Document $project, Agent $embeddingAgent, Context $usage, Log $log, ?Logger $logger): void + { + $results = []; + $embeddingAgent->getAdapter()->setModel($model); + $dimension = $embeddingAgent->getAdapter()->getEmbeddingDimension(); + + $totalDuration = 0; + $totalTokens = 0; + $totalErrors = 0; + foreach ($texts as $text) { + $embedding = []; + $error = ''; + try { + $embedResult = $embeddingAgent->embed($text); + $embedding = $embedResult['embedding'] ?? []; + $totalDuration += $embedResult['totalDuration'] ?? 0; + $totalTokens += $embedResult['tokensProcessed'] ?? 0; + } catch (\Exception $e) { + $error = 'Error while generating embedding'; + $totalErrors += 1; + if ($logger) { + $log->setNamespace("http"); + $log->setServer(System::getEnv('_APP_LOGGING_SERVICE_IDENTIFIER', \gethostname())); + $log->setVersion(System::getEnv('_APP_VERSION', 'UNKNOWN')); + $log->setType(Log::TYPE_ERROR); + $log->setMessage($e->getMessage()); + + $log->addTag('embeddingModel', $model); + $log->addTag('code', $e->getCode()); + $log->addTag('projectId', $project->getId()); + + $log->addExtra('file', $e->getFile()); + $log->addExtra('line', $e->getLine()); + $log->addExtra('trace', $e->getTraceAsString()); + + $logger->addLog($log); + } + } + + $results[] = new Document([ + 'model' => $model, + 'dimension' => $dimension, + 'embedding' => $embedding, + 'error' => $error + ]); + } + $embeddings = new Document([ + 'embeddings' => $results, + 'total' => \count($results), + ]); + + $response + ->setStatusCode(SwooleResponse::STATUS_CODE_OK) + ->dynamic($embeddings, $this->getBulkResponseModel()); + + $usage + ->addMetric(METRIC_EMBEDDINGS_TEXT, \count($texts)) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT), \count($texts)) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_TOKENS, $totalTokens) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_TOKENS), $totalTokens) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_DURATION, $totalDuration) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_DURATION), $totalDuration) + ->addMetric(METRIC_EMBEDDINGS_TEXT_TOTAL_ERROR, $totalErrors) + ->addMetric(\str_replace('{embeddingModel}', $model, METRIC_EMBEDDINGS_MODEL_TEXT_TOTAL_ERROR), $totalErrors); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php new file mode 100644 index 0000000000..a79632b105 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Get.php @@ -0,0 +1,49 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Get database') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'get', + description: '/docs/references/vectorsdb/get.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php new file mode 100644 index 0000000000..d8c1df5f04 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Logs/XList.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/logs') + ->desc('List database logs') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: 'logs', + name: 'listDatabaseLogs', + description: '/docs/references/vectorsdb/get-logs.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_LOG_LIST, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('queries', [], new Queries([new Limit(), new Offset()]), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Only supported methods are limit and offset', true) + ->inject('response') + ->inject('dbForProject') + ->inject('locale') + ->inject('geodb') + ->inject('authorization') + ->inject('audit') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php new file mode 100644 index 0000000000..cb67d3f7f1 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Create.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/transactions') + ->desc('Create transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'createTransaction', + description: '/docs/references/vectorsdb/create-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('ttl', APP_DATABASE_TXN_TTL_DEFAULT, new Range(min: APP_DATABASE_TXN_TTL_MIN, max: APP_DATABASE_TXN_TTL_MAX), 'Seconds before the transaction expires.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('user') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php new file mode 100644 index 0000000000..0ac2caecba --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Delete.php @@ -0,0 +1,55 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Delete transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'deleteTransaction', + description: '/docs/references/vectorsdb/delete-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_NOCONTENT, + model: UtopiaResponse::MODEL_NONE, + ) + ], + contentType: ContentType::NONE + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->inject('queueForDeletes') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php new file mode 100644 index 0000000000..fa4cc86cdd --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Get.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Get transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'getTransaction', + description: '/docs/references/vectorsdb/get-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php new file mode 100644 index 0000000000..27283cda49 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Operations/Create.php @@ -0,0 +1,61 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId/operations') + ->desc('Create operations') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'createOperations', + description: '/docs/references/vectorsdb/create-operations.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_CREATED, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('operations', [], new ArrayList(new Operation(type: 'documentsdb')), 'Array of staged operations.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('transactionState') + ->inject('plan') + ->inject('authorization') + ->inject('user') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php new file mode 100644 index 0000000000..f4bd4d67f5 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/Update.php @@ -0,0 +1,69 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/vectorsdb/transactions/:transactionId') + ->desc('Update transaction') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'updateTransaction', + description: '/docs/references/vectorsdb/update-transaction.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION, + ) + ], + contentType: ContentType::JSON + )) + ->param('transactionId', '', new UID(), 'Transaction ID.') + ->param('commit', false, new Boolean(), 'Commit transaction?', true) + ->param('rollback', false, new Boolean(), 'Rollback transaction?', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->inject('getDatabasesDB') + ->inject('user') + ->inject('transactionState') + ->inject('queueForDeletes') + ->inject('queueForEvents') + ->inject('usage') + ->inject('queueForRealtime') + ->inject('queueForFunctions') + ->inject('queueForWebhooks') + ->inject('authorization') + ->inject('eventProcessor') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php new file mode 100644 index 0000000000..fb95667ffb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Transactions/XList.php @@ -0,0 +1,54 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/transactions') + ->desc('List transactions') + ->groups(['api', 'database', 'transactions']) + ->label('scope', 'documents.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'transactions', + name: 'listTransactions', + description: '/docs/references/vectorsdb/list-transactions.md', + auth: [AuthType::ADMIN, AuthType::KEY, AuthType::SESSION, AuthType::JWT], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_TRANSACTION_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Transactions(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries).', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php new file mode 100644 index 0000000000..0b10d6d98b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Update.php @@ -0,0 +1,57 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/vectorsdb/:databaseId') + ->desc('Update database') + ->groups(['api', 'database', 'schema']) + ->label('scope', 'databases.write') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('event', 'databases.[databaseId].update') + ->label('audits.event', 'database.update') + ->label('audits.resource', 'database/{response.$id}') + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'update', + description: '/docs/references/vectorsdb/update.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE, + ) + ], + contentType: ContentType::JSON + )) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('name', null, new Text(128), 'Database name. Max length: 128 chars.') + ->param('enabled', true, new Boolean(), 'Is database enabled? When set to \'disabled\', users cannot access the database but Server SDKs with an API key can still read and write to the database. No data is lost when this is toggled.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php new file mode 100644 index 0000000000..051e2e39fa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/Get.php @@ -0,0 +1,59 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/:databaseId/usage') + ->desc('Get VectorsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: null, + name: 'getUsage', + description: '/docs/references/vectorsdb/get-database-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_VECTORSDB, + ) + ], + contentType: ContentType::JSON, + ), + ]) + ->param('databaseId', '', new UID(), 'Database ID.') + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->inject('authorization') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php new file mode 100644 index 0000000000..d91a5963c4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/Usage/XList.php @@ -0,0 +1,56 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb/usage') + ->desc('Get VectorsDB usage stats') + ->groups(['api', 'database', 'usage']) + ->label('scope', 'collections.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', [ + new Method( + namespace: 'vectorsDB', + group: null, + name: 'listUsage', + description: '/docs/references/vectorsdb/list-usage.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_USAGE_VECTORSDBS, + ) + ], + contentType: ContentType::JSON + ), + ]) + ->param('range', '30d', new WhiteList(['24h', '30d', '90d'], true), 'Date range.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php new file mode 100644 index 0000000000..e18a89c6a4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Http/VectorsDB/XList.php @@ -0,0 +1,53 @@ +setHttpMethod(self::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/vectorsdb') + ->desc('List databases') + ->groups(['api', 'database']) + ->label('scope', 'databases.read') + ->label('resourceType', RESOURCE_TYPE_DATABASES) + ->label('sdk', new Method( + namespace: 'vectorsDB', + group: 'vectorsdb', + name: 'list', + description: '/docs/references/vectorsdb/list.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: SwooleResponse::STATUS_CODE_OK, + model: UtopiaResponse::MODEL_DATABASE_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->param('queries', [], new Databases(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following columns: ' . implode(', ', Databases::ALLOWED_ATTRIBUTES), true) + ->param('search', '', new Text(256), 'Search term to filter your list results. Max length: 256 chars.', true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Http.php b/src/Appwrite/Platform/Modules/Databases/Services/Http.php index f683f537bc..5146382b56 100644 --- a/src/Appwrite/Platform/Modules/Databases/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Databases/Services/Http.php @@ -3,8 +3,10 @@ namespace Appwrite\Platform\Modules\Databases\Services; use Appwrite\Platform\Modules\Databases\Http\Init\Timeout; +use Appwrite\Platform\Modules\Databases\Services\Registry\DocumentsDB as DocumentsDBRegistry; use Appwrite\Platform\Modules\Databases\Services\Registry\Legacy as LegacyRegistry; -use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBRegistry; +use Appwrite\Platform\Modules\Databases\Services\Registry\TablesDB as TablesDBDBRegistry; +use Appwrite\Platform\Modules\Databases\Services\Registry\VectorsDB as VectorsDBRegistry; use Utopia\Platform\Service; class Http extends Service @@ -17,7 +19,9 @@ class Http extends Service foreach ([ LegacyRegistry::class, - TablesDBRegistry::class, + TablesDBDBRegistry::class, + DocumentsDBRegistry::class, + VectorsDBRegistry::class ] as $registrar) { new $registrar($this); } diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php new file mode 100644 index 0000000000..a1e3538cac --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/DocumentsDB.php @@ -0,0 +1,109 @@ +registerDatabaseActions($service); + $this->registerTableActions($service); + $this->registerIndexActions($service); + $this->registerRowActions($service); + $this->registerTransactionActions($service); + } + + private function registerDatabaseActions(Service $service): void + { + $service->addAction(CreateTablesDatabase::getName(), new CreateTablesDatabase()); + $service->addAction(GetTablesDatabase::getName(), new GetTablesDatabase()); + $service->addAction(UpdateTablesDatabase::getName(), new UpdateTablesDatabase()); + $service->addAction(DeleteTablesDatabase::getName(), new DeleteTablesDatabase()); + $service->addAction(ListTablesDatabase::getName(), new ListTablesDatabase()); + $service->addAction(GetTablesDatabaseUsage::getName(), new GetTablesDatabaseUsage()); + $service->addAction(ListTablesDatabaseUsage::getName(), new ListTablesDatabaseUsage()); + } + + private function registerTableActions(Service $service): void + { + $service->addAction(CreateTable::getName(), new CreateTable()); + $service->addAction(GetTable::getName(), new GetTable()); + $service->addAction(UpdateTable::getName(), new UpdateTable()); + $service->addAction(DeleteTable::getName(), new DeleteTable()); + $service->addAction(ListTables::getName(), new ListTables()); + $service->addAction(ListTableLogs::getName(), new ListTableLogs()); + $service->addAction(GetTableUsage::getName(), new GetTableUsage()); + } + + private function registerIndexActions(Service $service): void + { + $service->addAction(CreateColumnIndex::getName(), new CreateColumnIndex()); + $service->addAction(GetColumnIndex::getName(), new GetColumnIndex()); + $service->addAction(DeleteColumnIndex::getName(), new DeleteColumnIndex()); + $service->addAction(ListColumnIndexes::getName(), new ListColumnIndexes()); + } + + private function registerRowActions(Service $service): void + { + $service->addAction(CreateRow::getName(), new CreateRow()); + $service->addAction(GetRow::getName(), new GetRow()); + $service->addAction(UpdateRow::getName(), new UpdateRow()); + $service->addAction(UpdateRows::getName(), new UpdateRows()); + $service->addAction(UpsertRow::getName(), new UpsertRow()); + $service->addAction(UpsertRows::getName(), new UpsertRows()); + $service->addAction(DeleteRow::getName(), new DeleteRow()); + $service->addAction(DeleteRows::getName(), new DeleteRows()); + $service->addAction(ListRows::getName(), new ListRows()); + $service->addAction(ListRowLogs::getName(), new ListRowLogs()); + $service->addAction(IncrementRowColumn::getName(), new IncrementRowColumn()); + $service->addAction(DecrementRowColumn::getName(), new DecrementRowColumn()); + } + + private function registerTransactionActions(Service $service): void + { + $service->addAction(CreateTransaction::getName(), new CreateTransaction()); + $service->addAction(GetTransaction::getName(), new GetTransaction()); + $service->addAction(UpdateTransaction::getName(), new UpdateTransaction()); + $service->addAction(DeleteTransaction::getName(), new DeleteTransaction()); + $service->addAction(ListTransactions::getName(), new ListTransactions()); + $service->addAction(CreateOperations::getName(), new CreateOperations()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php new file mode 100644 index 0000000000..5d12b14b1a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Databases/Services/Registry/VectorsDB.php @@ -0,0 +1,112 @@ +registerDatabaseActions($service); + $this->registerCollectionActions($service); + $this->registerIndexActions($service); + $this->registerDocumentActions($service); + $this->registerEmbeddingActions($service); + $this->registerTransactionActions($service); + } + + private function registerDatabaseActions(Service $service): void + { + $service->addAction(CreateVectorDatabase::getName(), new CreateVectorDatabase()); + $service->addAction(GetVectorDatabase::getName(), new GetVectorDatabase()); + $service->addAction(UpdateVectorDatabase::getName(), new UpdateVectorDatabase()); + $service->addAction(DeleteVectorDatabase::getName(), new DeleteVectorDatabase()); + $service->addAction(ListVectorDatabases::getName(), new ListVectorDatabases()); + $service->addAction(GetVectorDatabaseUsage::getName(), new GetVectorDatabaseUsage()); + $service->addAction(ListVectorDatabaseUsage::getName(), new ListVectorDatabaseUsage()); + } + + private function registerCollectionActions(Service $service): void + { + $service->addAction(CreateCollection::getName(), new CreateCollection()); + $service->addAction(GetCollection::getName(), new GetCollection()); + $service->addAction(UpdateCollection::getName(), new UpdateCollection()); + $service->addAction(DeleteCollection::getName(), new DeleteCollection()); + $service->addAction(ListCollections::getName(), new ListCollections()); + $service->addAction(ListCollectionLogs::getName(), new ListCollectionLogs()); + $service->addAction(GetCollectionUsage::getName(), new GetCollectionUsage()); + } + + private function registerIndexActions(Service $service): void + { + $service->addAction(CreateIndex::getName(), new CreateIndex()); + $service->addAction(GetIndex::getName(), new GetIndex()); + $service->addAction(DeleteIndex::getName(), new DeleteIndex()); + $service->addAction(ListIndexes::getName(), new ListIndexes()); + } + + private function registerDocumentActions(Service $service): void + { + $service->addAction(CreateDocument::getName(), new CreateDocument()); + $service->addAction(UpdateDocument::getName(), new UpdateDocument()); + $service->addAction(UpsertDocument::getName(), new UpsertDocument()); + $service->addAction(GetDocument::getName(), new GetDocument()); + $service->addAction(ListDocuments::getName(), new ListDocuments()); + $service->addAction(DeleteDocument::getName(), new DeleteDocument()); + $service->addAction(UpdateDocuments::getName(), new UpdateDocuments()); + $service->addAction(UpsertDocuments::getName(), new UpsertDocuments()); + $service->addAction(DeleteDocuments::getName(), new DeleteDocuments()); + $service->addAction(ListDocumentLogs::getName(), new ListDocumentLogs()); + } + + private function registerTransactionActions(Service $service): void + { + $service->addAction(CreateTransaction::getName(), new CreateTransaction()); + $service->addAction(GetTransaction::getName(), new GetTransaction()); + $service->addAction(UpdateTransaction::getName(), new UpdateTransaction()); + $service->addAction(DeleteTransaction::getName(), new DeleteTransaction()); + $service->addAction(ListTransactions::getName(), new ListTransactions()); + $service->addAction(CreateOperations::getName(), new CreateOperations()); + } + + private function registerEmbeddingActions(Service $service): void + { + $service->addAction(CreateTextEmbeddings::getName(), new CreateTextEmbeddings()); + } +} diff --git a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php index 60d70b7942..66ed3e0eab 100644 --- a/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php +++ b/src/Appwrite/Platform/Modules/Databases/Workers/Databases.php @@ -36,6 +36,7 @@ class Databases extends Action ->inject('project') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('getDatabasesDB') ->inject('queueForRealtime') ->inject('log') ->callback($this->action(...)); @@ -51,7 +52,7 @@ class Databases extends Action * @return void * @throws \Exception */ - public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime, Log $log): void + public function action(Message $message, Document $project, Database $dbForPlatform, Database $dbForProject, callable $getDatabasesDB, Realtime $queueForRealtime, Log $log): void { $payload = $message->getPayload() ?? []; @@ -63,7 +64,10 @@ class Databases extends Action $document = new Document($payload['row'] ?? $payload['document'] ?? []); $collection = new Document($payload['table'] ?? $payload['collection'] ?? []); $database = new Document($payload['database'] ?? []); - + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($database); $log->addTag('projectId', $project->getId()); $log->addTag('type', $type); @@ -74,12 +78,12 @@ class Databases extends Action $log->addTag('databaseId', $database->getId()); match (\strval($type)) { - DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject), - DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject), + DATABASE_TYPE_DELETE_DATABASE => $this->deleteDatabase($database, $dbForProject, $dbForDatabases), + DATABASE_TYPE_DELETE_COLLECTION => $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases), DATABASE_TYPE_CREATE_ATTRIBUTE => $this->createAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), - DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $queueForRealtime), + DATABASE_TYPE_DELETE_ATTRIBUTE => $this->deleteAttribute($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), + DATABASE_TYPE_CREATE_INDEX => $this->createIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), + DATABASE_TYPE_DELETE_INDEX => $this->deleteIndex($database, $collection, $document, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime), default => throw new Exception('No database operation for type: ' . \strval($type)), }; @@ -244,6 +248,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -251,7 +256,7 @@ class Databases extends Action * @throws \Exception * @throws \Throwable **/ - private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function deleteAttribute(Document $database, Document $collection, Document $attribute, Document $project, Database $dbForPlatform, Database $dbForDatabases, Database $dbForProject, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -386,7 +391,7 @@ class Databases extends Action } if ($exists) { // Delete the duplicate if created, else update in db - $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $queueForRealtime); + $this->deleteIndex($database, $collection, $index, $project, $dbForPlatform, $dbForProject, $dbForDatabases, $queueForRealtime); } else { $dbForProject->updateDocument('indexes', $index->getId(), new Document([ 'attributes' => $index->getAttribute('attributes'), @@ -415,6 +420,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -423,7 +429,7 @@ class Databases extends Action * @throws DatabaseException * @throws \Throwable */ - private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function createIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -443,7 +449,7 @@ class Databases extends Action $project = $dbForPlatform->getDocument('projects', $projectId); try { - if (!$dbForProject->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) { + if (!$dbForDatabases->createIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key, $type, $attributes, $lengths, $orders)) { throw new DatabaseException('Failed to create Index'); } $dbForProject->updateDocument('indexes', $index->getId(), $index->setAttribute('status', 'available')); @@ -473,6 +479,7 @@ class Databases extends Action * @param Document $project * @param Database $dbForPlatform * @param Database $dbForProject + * @param Database $dbForDatabases * @param Realtime $queueForRealtime * @return void * @throws Authorization @@ -481,7 +488,7 @@ class Databases extends Action * @throws DatabaseException * @throws \Throwable */ - private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Realtime $queueForRealtime): void + private function deleteIndex(Document $database, Document $collection, Document $index, Document $project, Database $dbForPlatform, Database $dbForProject, Database $dbForDatabases, Realtime $queueForRealtime): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -497,7 +504,7 @@ class Databases extends Action $project = $dbForPlatform->getDocument('projects', $projectId); try { - if ($status !== 'failed' && !$dbForProject->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) { + if ($status !== 'failed' && !$dbForDatabases->deleteIndex('database_' . $database->getSequence() . '_collection_' . $collection->getSequence(), $key)) { throw new DatabaseException('Failed to delete index'); } $dbForProject->deleteDocument('indexes', $index->getId()); @@ -525,14 +532,15 @@ class Databases extends Action /** * @param Document $database - * @param $dbForProject + * @param Database $dbForProject + * @param Database $dbForDatabases * @return void * @throws Exception */ - protected function deleteDatabase(Document $database, $dbForProject): void + protected function deleteDatabase(Document $database, Database $dbForProject, Database $dbForDatabases): void { - $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject) { - $this->deleteCollection($database, $collection, $dbForProject); + $this->deleteByGroup('database_' . $database->getSequence(), [], $dbForProject, function ($collection) use ($database, $dbForProject, $dbForDatabases) { + $this->deleteCollection($database, $collection, $dbForProject, $dbForDatabases); }); $dbForProject->deleteCollection('database_' . $database->getSequence()); @@ -542,6 +550,7 @@ class Databases extends Action * @param Document $database * @param Document $collection * @param Database $dbForProject + * @param Database $dbForDatabases * @return void * @throws Authorization * @throws Conflict @@ -550,7 +559,7 @@ class Databases extends Action * @throws Structure * @throws Exception */ - protected function deleteCollection(Document $database, Document $collection, Database $dbForProject): void + protected function deleteCollection(Document $database, Document $collection, Database $dbForProject, Database $dbForDatabases): void { if ($collection->isEmpty()) { throw new Exception('Missing collection/table'); @@ -560,7 +569,7 @@ class Databases extends Action $collectionInternalId = $collection->getSequence(); $databaseInternalId = $database->getSequence(); - $dbForProject->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence()); + $dbForDatabases->deleteCollection('database_' . $databaseInternalId . '_collection_' . $collection->getSequence()); /** * Related collections relating to current collection diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index ee33abe9e1..c6d25a15fc 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -119,7 +119,7 @@ class Create extends Base Document $project, Database $dbForProject, Database $dbForPlatform, - Document $user, + User $user, Event $queueForEvents, Context $usage, Func $queueForFunctions, @@ -171,8 +171,8 @@ class Create extends Base /* @var Document $function */ $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php index 70912cf58c..aec9d56543 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Get.php @@ -53,6 +53,7 @@ class Get extends Base ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -61,12 +62,13 @@ class Get extends Base string $executionId, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php index dcc3f6ee9c..b12980b222 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/XList.php @@ -62,6 +62,7 @@ class XList extends Base ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -71,12 +72,13 @@ class XList extends Base bool $includeTotal, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $function = $authorization->skip(fn () => $dbForProject->getDocument('functions', $functionId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($function->isEmpty() || (!$function->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::FUNCTION_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Init.php b/src/Appwrite/Platform/Modules/Project/Http/Init.php new file mode 100644 index 0000000000..ff191ade6c --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Init.php @@ -0,0 +1,32 @@ +setType(Action::TYPE_INIT) + ->groups(['project']) + ->inject('project') + ->callback(function (Document $project) { + if ($project->getId() === 'console') { + throw new Exception(Exception::GENERAL_ACCESS_FORBIDDEN); + } + + if ($project->isEmpty()) { + throw new Exception(Exception::PROJECT_NOT_FOUND); + } + }); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php new file mode 100644 index 0000000000..acc39bb68d --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Create.php @@ -0,0 +1,108 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_POST) + ->setHttpPath('/v1/project/variables') + ->desc('Create project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'variables.[variableId].create') + ->label('audits.event', 'project.variable.create') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'createVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new CustomId(false, $dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.', false, ['dbForProject']) + ->param('key', null, new Text(Database::LENGTH_KEY), 'Variable key. Max length: ' . Database::LENGTH_KEY . ' chars.') + ->param('value', null, new Text(8192, 0), 'Variable value. Max length: 8192 chars.') + ->param('secret', true, new Boolean(), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + string $key, + string $value, + bool $secret, + Response $response, + QueueEvent $queueForEvents, + Database $dbForProject, + ) { + $variableId = ($variableId == 'unique()') ? ID::unique() : $variableId; + + $variable = new Document([ + '$id' => $variableId, + '$permissions' => [], + 'resourceInternalId' => '', // Already in project DB anyway + 'resourceId' => '', // Already in project DB anyway + 'resourceType' => 'project', + 'key' => $key, + 'value' => $value, + 'secret' => $secret, + 'search' => implode(' ', [$variableId, $key, 'project']), + ]); + + try { + $variable = $dbForProject->createDocument('variables', $variable); + } catch (DuplicateException $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live' => false + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response + ->setStatusCode(Response::STATUS_CODE_CREATED) + ->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php new file mode 100644 index 0000000000..ac47ec3dbb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Delete.php @@ -0,0 +1,88 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_DELETE) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Delete project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'variables.[variableId].delete') + ->label('audits.event', 'project.variable.delete') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'deleteVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + Response $response, + Database $dbForProject, + Event $queueForEvents, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + if (!$dbForProject->deleteDocument('variables', $variable->getId())) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed to remove document from DB'); + }; + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live' => false + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response->noContent(); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php new file mode 100644 index 0000000000..6de51dacaf --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Get.php @@ -0,0 +1,67 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Get project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'getVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + Response $response, + Database $dbForProject, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php new file mode 100644 index 0000000000..61a943b618 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/Update.php @@ -0,0 +1,121 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_PUT) + ->setHttpPath('/v1/project/variables/:variableId') + ->desc('Update project variable') + ->groups(['api', 'project']) + ->label('scope', 'project.write') + ->label('event', 'variables.[variableId].update') + ->label('audits.event', 'project.variable.update') + ->label('audits.resource', 'project.variable/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'updateVariable', + description: <<param('variableId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'Variable ID.', false, ['dbForProject']) + ->param('key', null, new Nullable(new Text(255, 0)), 'Variable key. Max length: 255 chars.', true) + ->param('value', null, new Nullable(new Text(8192, 0)), 'Variable value. Max length: 8192 chars.', true) + ->param('secret', null, new Nullable(new Boolean()), 'Secret variables can be updated or deleted, but only projects can read them during build and runtime.', true) + ->inject('response') + ->inject('queueForEvents') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + public function action( + string $variableId, + ?string $key, + ?string $value, + ?bool $secret, + Response $response, + QueueEvent $queueForEvents, + Database $dbForProject, + ) { + $variable = $dbForProject->getDocument('variables', $variableId); + + if ($variable->isEmpty() || $variable->getAttribute('resourceType', '') !== 'project') { + throw new Exception(Exception::VARIABLE_NOT_FOUND); + } + + $isSecretVariable = $variable->getAttribute('secret', false) === true; + if ($isSecretVariable && $secret === false) { + throw new Exception(Exception::VARIABLE_CANNOT_UNSET_SECRET); + } + + if (\is_null($key) && \is_null($value) && \is_null($secret)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID); + } + + $updates = new Document(); + + if (!\is_null($key)) { + $updates->setAttribute('key', $key); + $updates->setAttribute('search', implode(' ', [$variableId, $key, 'project'])); + } + + if (!\is_null($value)) { + $updates->setAttribute('value', $value); + } + + if (!\is_null($secret)) { + $updates->setAttribute('secret', $secret); + } + + try { + $variable = $dbForProject->updateDocument('variables', $variable->getId(), $updates); + } catch (Duplicate $th) { + throw new Exception(Exception::VARIABLE_ALREADY_EXISTS); + } + + foreach (['functions', 'sites'] as $collection) { + $dbForProject->updateDocuments($collection, new Document([ + 'live' => false + ])); + } + + $queueForEvents->setParam('variableId', $variable->getId()); + + $response->dynamic($variable, Response::MODEL_VARIABLE); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php new file mode 100644 index 0000000000..cd11fe68c6 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/Variables/XList.php @@ -0,0 +1,116 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/variables') + ->desc('List project variables') + ->groups(['api', 'project']) + ->label('scope', 'project.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'variables', + name: 'listVariables', + description: <<param('queries', [], new Variables(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Variables::ALLOWED_ATTRIBUTES), true) + ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) + ->inject('project') + ->inject('response') + ->inject('dbForProject') + ->callback($this->action(...)); + } + + /** + * @param array $queries + */ + public function action( + array $queries, + bool $includeTotal, + Document $project, + Response $response, + Database $dbForProject, + ) { + try { + $queries = Query::parseQueries($queries); + } catch (QueryException $e) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage()); + } + + $queries[] = Query::equal('resourceType', ['project']); + + $cursor = Query::getCursorQueries($queries, false); + $cursor = \reset($cursor); + + if ($cursor !== false) { + $validator = new Cursor(); + if (!$validator->isValid($cursor)) { + throw new Exception(Exception::GENERAL_QUERY_INVALID, $validator->getDescription()); + } + + $variableId = $cursor->getValue(); + $cursorDocument = $dbForProject->findOne('variables', [ + Query::equal('$id', [$variableId]), + Query::equal('resourceType', ['project']), + ]); + + if ($cursorDocument->isEmpty()) { + throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Variable '{$variableId}' for the 'cursor' value not found."); + } + + $cursor->setValue($cursorDocument); + } + + $filterQueries = Query::groupByType($queries)['filters']; + + try { + $variables = $dbForProject->find('variables', $queries); + $total = $includeTotal ? $dbForProject->count('variables', $filterQueries, APP_LIMIT_COUNT) : 0; + } catch (OrderException $e) { + throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, "The order attribute '{$e->getAttribute()}' had a null value. Cursor pagination requires all documents order attribute values are non-null."); + } + + $response->dynamic(new Document([ + 'variables' => $variables, + 'total' => $total, + ]), Response::MODEL_VARIABLE_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Module.php b/src/Appwrite/Platform/Modules/Project/Module.php new file mode 100644 index 0000000000..ab6f445853 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Module.php @@ -0,0 +1,14 @@ +addService('http', new Http()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php new file mode 100644 index 0000000000..949fb2bcd9 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -0,0 +1,29 @@ +type = Service::TYPE_HTTP; + + // Hooks + $this->addAction(Init::getName(), new Init()); + + // Project + $this->addAction(CreateVariable::getName(), new CreateVariable()); + $this->addAction(ListVariables::getName(), new ListVariables()); + $this->addAction(GetVariable::getName(), new GetVariable()); + $this->addAction(DeleteVariable::getName(), new DeleteVariable()); + $this->addAction(UpdateVariable::getName(), new UpdateVariable()); + } +} diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php index 827dbc8dd9..c67601dae9 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php @@ -103,7 +103,7 @@ class Create extends Action Request $request, Response $response, Database $dbForProject, - Document $user, + User $user, Event $queueForEvents, string $mode, Device $deviceForFiles, @@ -112,8 +112,8 @@ class Create extends Action ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php index ca376842e2..aa7f14d2ed 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Delete.php @@ -66,6 +66,7 @@ class Delete extends Action ->inject('deviceForFiles') ->inject('queueForDeletes') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -77,12 +78,13 @@ class Delete extends Action Event $queueForEvents, Device $deviceForFiles, DeleteEvent $queueForDeletes, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php index 042ae76565..c876004319 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Download/Get.php @@ -70,6 +70,7 @@ class Get extends Action ->inject('resourceToken') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -84,12 +85,13 @@ class Get extends Action Document $resourceToken, Device $deviceForFiles, Authorization $authorization, + User $user, ) { /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php index caaab29efc..c9ce5796eb 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Get.php @@ -51,6 +51,7 @@ class Get extends Action ->inject('response') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -59,12 +60,13 @@ class Get extends Action string $fileId, Response $response, Database $dbForProject, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php index 63a72fc683..a5e48be478 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Preview/Get.php @@ -92,6 +92,7 @@ class Get extends Action ->inject('deviceForLocal') ->inject('project') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -117,7 +118,8 @@ class Get extends Action Device $deviceForFiles, Device $deviceForLocal, Document $project, - Authorization $authorization + Authorization $authorization, + User $user ) { if (!\extension_loaded('imagick')) { @@ -127,8 +129,8 @@ class Get extends Action /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -271,7 +273,7 @@ class Get extends Action $contentType = (\array_key_exists($output, $outputs)) ? $outputs[$output] : $outputs['jpg']; //Do not update transformedAt if it's a console user - if (!User::isPrivileged($authorization->getRoles())) { + if (!$user->isPrivileged($authorization->getRoles())) { $transformedAt = $file->getAttribute('transformedAt', ''); if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $transformedAt) { $file->setAttribute('transformedAt', DateTime::now()); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php index c475c53d24..5b3fd02370 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Push/Get.php @@ -52,6 +52,7 @@ class Get extends Action ->inject('mode') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -66,7 +67,8 @@ class Get extends Action Document $project, string $mode, Device $deviceForFiles, - Authorization $authorization + Authorization $authorization, + User $user ) { $decoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 3600, 0); @@ -88,8 +90,8 @@ class Get extends Action $disposition = $decoded['disposition'] ?? 'inline'; $dbForProject = $isInternal ? $dbForPlatform : $dbForProject; - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php index 57856c1564..8e69468170 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Update.php @@ -64,6 +64,7 @@ class Update extends Action ->inject('dbForProject') ->inject('queueForEvents') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -75,12 +76,13 @@ class Update extends Action Response $response, Database $dbForProject, Event $queueForEvents, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); @@ -108,7 +110,7 @@ class Update extends Action // Users can only manage their own roles, API keys and Admin users can manage any $roles = $authorization->getRoles(); - if (!User::isApp($roles) && !User::isPrivileged($roles) && !\is_null($permissions)) { + if (!$user->isApp($roles) && !$user->isPrivileged($roles) && !\is_null($permissions)) { foreach (Database::PERMISSIONS as $type) { foreach ($permissions as $permission) { $permission = Permission::parse($permission); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php index cba6c2fa13..b2f00da6d2 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/View/Get.php @@ -71,6 +71,7 @@ class Get extends Action ->inject('resourceToken') ->inject('deviceForFiles') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -84,13 +85,14 @@ class Get extends Action string $mode, Document $resourceToken, Device $deviceForFiles, - Authorization $authorization + Authorization $authorization, + User $user ) { /* @type Document $bucket */ $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php index 6de360ae0e..945c4bfd7c 100644 --- a/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/XList.php @@ -63,6 +63,7 @@ class XList extends Action ->inject('dbForProject') ->inject('mode') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } @@ -74,12 +75,13 @@ class XList extends Action Response $response, Database $dbForProject, string $mode, - Authorization $authorization + Authorization $authorization, + User $user ) { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php index 0632aea3dd..777184f2f2 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Create.php @@ -98,10 +98,10 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken) + public function action(string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, User $user, Database $dbForProject, Authorization $authorization, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, Context $usage, array $plan, Password $proofForPassword, Token $proofForToken) { - $isAppUser = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if (empty($url)) { if (! $isAppUser && ! $isPrivilegedUser) { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php index 9bfbd8528e..f3fd9a4bb9 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Get.php @@ -52,10 +52,11 @@ class Get extends Action ->inject('project') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization) + public function action(string $teamId, string $membershipId, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user) { $team = $dbForProject->getDocument('teams', $teamId); @@ -76,25 +77,25 @@ class Get extends Action ]; $roles = $authorization->getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; }, $membershipsPrivacy); - $user = !empty(array_filter($membershipsPrivacy)) + $memberUser = !empty(array_filter($membershipsPrivacy)) ? $dbForProject->getDocument('users', $membership->getAttribute('userId')) : new Document(); if ($membershipsPrivacy['mfa']) { - $mfa = $user->getAttribute('mfa', false); + $mfa = $memberUser->getAttribute('mfa', false); if ($mfa) { - $totp = TOTP::getAuthenticatorFromUser($user); + $totp = TOTP::getAuthenticatorFromUser($memberUser); $totpEnabled = $totp && $totp->getAttribute('verified', false); - $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false); - $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false); + $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false); + $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false); if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) { $mfa = false; @@ -105,11 +106,11 @@ class Get extends Action } if ($membershipsPrivacy['userName']) { - $membership->setAttribute('userName', $user->getAttribute('name')); + $membership->setAttribute('userName', $memberUser->getAttribute('name')); } if ($membershipsPrivacy['userEmail']) { - $membership->setAttribute('userEmail', $user->getAttribute('email')); + $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } $membership->setAttribute('teamName', $team->getAttribute('name')); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php index a935055163..540dc8a871 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/Update.php @@ -66,7 +66,7 @@ class Update extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, Document $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents) + public function action(string $teamId, string $membershipId, array $roles, Request $request, Response $response, User $user, Document $project, Database $dbForProject, Authorization $authorization, Event $queueForEvents) { $team = $dbForProject->getDocument('teams', $teamId); if ($team->isEmpty()) { @@ -83,8 +83,8 @@ class Update extends Action throw new Exception(Exception::USER_NOT_FOUND); } - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $isAppUser = User::isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); $isOwner = $authorization->hasRole('team:' . $team->getId() . '/owner'); if ($project->getId() === 'console') { diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php index ba59f48b43..364f92e1c5 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Memberships/XList.php @@ -61,10 +61,11 @@ class XList extends Action ->inject('project') ->inject('dbForProject') ->inject('authorization') + ->inject('user') ->callback($this->action(...)); } - public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization) + public function action(string $teamId, array $queries, string $search, bool $includeTotal, Response $response, Document $project, Database $dbForProject, Authorization $authorization, User $user) { $team = $dbForProject->getDocument('teams', $teamId); @@ -129,26 +130,26 @@ class XList extends Action ]; $roles = $authorization->getRoles(); - $isPrivilegedUser = User::isPrivileged($roles); - $isAppUser = User::isApp($roles); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); $membershipsPrivacy = array_map(function ($privacy) use ($isPrivilegedUser, $isAppUser) { return $privacy || $isPrivilegedUser || $isAppUser; }, $membershipsPrivacy); $memberships = array_map(function ($membership) use ($dbForProject, $team, $membershipsPrivacy) { - $user = !empty(array_filter($membershipsPrivacy)) + $memberUser = !empty(array_filter($membershipsPrivacy)) ? $dbForProject->getDocument('users', $membership->getAttribute('userId')) : new Document(); if ($membershipsPrivacy['mfa']) { - $mfa = $user->getAttribute('mfa', false); + $mfa = $memberUser->getAttribute('mfa', false); if ($mfa) { - $totp = TOTP::getAuthenticatorFromUser($user); + $totp = TOTP::getAuthenticatorFromUser($memberUser); $totpEnabled = $totp && $totp->getAttribute('verified', false); - $emailEnabled = $user->getAttribute('email', false) && $user->getAttribute('emailVerification', false); - $phoneEnabled = $user->getAttribute('phone', false) && $user->getAttribute('phoneVerification', false); + $emailEnabled = $memberUser->getAttribute('email', false) && $memberUser->getAttribute('emailVerification', false); + $phoneEnabled = $memberUser->getAttribute('phone', false) && $memberUser->getAttribute('phoneVerification', false); if (!$totpEnabled && !$emailEnabled && !$phoneEnabled) { $mfa = false; @@ -159,11 +160,11 @@ class XList extends Action } if ($membershipsPrivacy['userName']) { - $membership->setAttribute('userName', $user->getAttribute('name')); + $membership->setAttribute('userName', $memberUser->getAttribute('name')); } if ($membershipsPrivacy['userEmail']) { - $membership->setAttribute('userEmail', $user->getAttribute('email')); + $membership->setAttribute('userEmail', $memberUser->getAttribute('email')); } $membership->setAttribute('teamName', $team->getAttribute('name')); diff --git a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php index ae20017e76..0d20a58b6b 100644 --- a/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php +++ b/src/Appwrite/Platform/Modules/Teams/Http/Teams/Create.php @@ -68,10 +68,10 @@ class Create extends Action ->callback($this->action(...)); } - public function action(string $teamId, string $name, array $roles, Response $response, Document $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents) + public function action(string $teamId, string $name, array $roles, Response $response, User $user, Database $dbForProject, Authorization $authorization, Event $queueForEvents) { - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); - $isAppUser = User::isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); + $isAppUser = $user->isApp($authorization->getRoles()); $teamId = $teamId == 'unique()' ? ID::unique() : $teamId; diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php index 5f1bd55788..934074d3c2 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Action.php @@ -11,12 +11,12 @@ use Utopia\Platform\Action as UtopiaAction; class Action extends UtopiaAction { - protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, string $bucketId, string $fileId): array + protected function getFileAndBucket(Database $dbForProject, Authorization $authorization, User $user, string $bucketId, string $fileId): array { $bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId)); - $isAPIKey = User::isApp($authorization->getRoles()); - $isPrivilegedUser = User::isPrivileged($authorization->getRoles()); + $isAPIKey = $user->isApp($authorization->getRoles()); + $isPrivilegedUser = $user->isPrivileged($authorization->getRoles()); if ($bucket->isEmpty() || (!$bucket->getAttribute('enabled') && !$isAPIKey && !$isPrivilegedUser)) { throw new Exception(Exception::STORAGE_BUCKET_NOT_FOUND); diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php index 10257d3603..1c2bd692ef 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/Create.php @@ -8,6 +8,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Response; use Utopia\Auth\Proofs\Token; use Utopia\Database\Database; @@ -64,19 +65,20 @@ class Create extends Action ->param('fileId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'File unique ID.', false, ['dbForProject']) ->param('expire', null, new Nullable(new DatetimeValidator(requireDateInFuture: true)), 'Token expiry date', true) ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('queueForEvents') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $bucketId, string $fileId, ?string $expire, Response $response, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void + public function action(string $bucketId, string $fileId, ?string $expire, Response $response, User $user, Database $dbForProject, Event $queueForEvents, Authorization $authorization): void { /** * @var Document $bucket * @var Document $file */ - ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId); + ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId); $fileSecurity = $bucket->getAttribute('fileSecurity', false); $bucketPermission = $authorization->isValid(new Input(Database::PERMISSION_UPDATE, $bucket->getUpdate())); diff --git a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php index 3d7be9bf81..5a93a06f26 100644 --- a/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php +++ b/src/Appwrite/Platform/Modules/Tokens/Http/Tokens/Buckets/Files/XList.php @@ -7,6 +7,7 @@ use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; +use Appwrite\Utopia\Database\Documents\User; use Appwrite\Utopia\Database\Validator\Queries\FileTokens; use Appwrite\Utopia\Response; use Utopia\Database\Database; @@ -57,14 +58,15 @@ class XList extends Action ->param('queries', [], new FileTokens(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', FileTokens::ALLOWED_ATTRIBUTES), true) ->param('total', true, new Boolean(true), 'When set to false, the total count returned will be 0 and will not be calculated.', true) ->inject('response') + ->inject('user') ->inject('dbForProject') ->inject('authorization') ->callback($this->action(...)); } - public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, Database $dbForProject, Authorization $authorization) + public function action(string $bucketId, string $fileId, array $queries, bool $includeTotal, Response $response, User $user, Database $dbForProject, Authorization $authorization) { - ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $bucketId, $fileId); + ['bucket' => $bucket, 'file' => $file] = $this->getFileAndBucket($dbForProject, $authorization, $user, $bucketId, $fileId); $queries = Query::parseQueries($queries); $queries[] = Query::equal('resourceType', [TOKENS_RESOURCE_TYPE_FILES]); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php index 638ceab59e..ae730c3f74 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Deployment.php @@ -70,6 +70,8 @@ trait Deployment throw new Exception(Exception::PROJECT_NOT_FOUND, 'Repository references non-existent project'); } + $this->beforeCreateGitDeployment($project, $repository, $dbForPlatform, $authorization); + try { $dsn = new DSN($project->getAttribute('database')); $databaseName = $dsn->getHost(); @@ -127,7 +129,6 @@ trait Deployment Span::add("{$logBase}.authorized", $isAuthorized); - $commentStatus = 'waiting'; $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') === 'disabled' ? 'http' : 'https'; $hostname = $platform['consoleHostname'] ?? ''; @@ -135,6 +136,34 @@ trait Deployment $action = $isAuthorized ? ['type' => 'logs'] : ['type' => 'authorize', 'url' => $authorizeUrl]; + $commentStatus = 'waiting'; + $commentPreviewUrl = ''; + + // If this action was triggered by pull request, use most up to date details in comment + if (!empty($providerPullRequestId)) { + $existingDeployment = $authorization->skip(fn () => $dbForProject->findOne('deployments', [ + Query::equal('resourceInternalId', [$resource->getSequence()]), + Query::equal('resourceType', [$resourceCollection]), + Query::equal('providerCommitHash', [$providerCommitHash]), + Query::equal('providerBranch', [$providerBranch]), + Query::orderDesc('$createdAt') + ])); + + $commentStatus = $existingDeployment->getAttribute('status', 'waiting'); + + if ($resource->getCollection() === 'sites') { + $previewRule = $authorization->skip(fn () => $dbForPlatform->findOne('rules', [ + Query::equal('projectInternalId', [$project->getSequence()]), + Query::equal('type', ['deployment']), // Not redirect + Query::equal('trigger', ['deployment']), // Preview - Not manual + Query::equal('deploymentResourceType', ['site']), // Not function + Query::equal('deploymentInternalId', [$existingDeployment->getSequence()]), + ])); + + $commentPreviewUrl = !$previewRule->isEmpty() ? ("{$protocol}://" . $previewRule->getAttribute('domain', '')) : ''; + } + } + $latestCommentId = ''; if (!empty($providerPullRequestId) && $resource->getAttribute('providerSilentMode', false) === false) { @@ -173,7 +202,7 @@ trait Deployment try { $comment = new Comment($platform); $comment->parseComment($github->getComment($owner, $repositoryName, $latestCommentId)); - $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, ''); + $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $commentPreviewUrl); $latestCommentId = \strval($github->updateComment($owner, $repositoryName, $latestCommentId, $comment->generateComment())); } finally { @@ -182,7 +211,7 @@ trait Deployment } } else { $comment = new Comment($platform); - $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, ''); + $comment->addBuild($project, $resource, $resourceType, $commentStatus, $deploymentId, $action, $commentPreviewUrl); $latestCommentId = \strval($github->createComment($owner, $repositoryName, $providerPullRequestId, $comment->generateComment())); if (!empty($latestCommentId)) { @@ -274,6 +303,19 @@ trait Deployment continue; } + if (!empty($providerPullRequestId)) { + // Update comment ID so running build can update comment + $authorization->skip(fn () => $dbForProject->updateDocuments('deployments', new Document([ + 'providerCommentId' => \strval($latestCommentId) + ]), [ + Query::equal('providerCommitHash', [$providerCommitHash]), + Query::equal('providerBranch', [$providerBranch]), + ])); + + // Skip rest - prevent double deployments (previous one was made by push) + continue; + } + $commands = []; if (!empty($resource->getAttribute('installCommand', ''))) { $commands[] = $resource->getAttribute('installCommand', ''); @@ -521,6 +563,10 @@ trait Deployment } } + protected function beforeCreateGitDeployment(Document $project, Document $repository, Database $dbForPlatform, Authorization $authorization): void + { + } + protected function getBuildQueueName(Document $project, Database $dbForPlatform, Authorization $authorization): string { return System::getEnv('_APP_BUILDS_QUEUE_NAME', Event::BUILDS_QUEUE_NAME); diff --git a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php index c614c80041..a2fa44c613 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/GitHub/Events/Create.php @@ -65,7 +65,7 @@ class Create extends Action $signature = $request->getHeader('x-hub-signature-256', ''); $secretKey = System::getEnv('_APP_VCS_GITHUB_WEBHOOK_SECRET', ''); - $valid = empty($signature) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey); + $valid = empty($secretKey) ? true : $github->validateWebhookEvent($payload, $signature, $secretKey); Span::add('vcs.github.event.signature.valid', $valid); if (!$valid) { @@ -162,8 +162,8 @@ class Create extends Action Query::limit(100), ])); - // Create new deployment only on push (not committed by us) and not when branch is created or deleted - if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchCreated && !$providerBranchDeleted) { + // Create new deployment only on push (not committed by us) and not when branch is deleted + if ($providerCommitAuthorEmail !== APP_VCS_GITHUB_EMAIL && !$providerBranchDeleted) { $this->createGitDeployments($github, $providerInstallationId, $repositories, $providerBranch, $providerBranchUrl, $providerRepositoryName, $providerRepositoryUrl, $providerRepositoryOwner, $providerCommitHash, $providerCommitAuthorName, $providerCommitAuthorUrl, $providerCommitMessage, $providerCommitUrl, '', false, $dbForPlatform, $authorization, $queueForBuilds, $getProjectDB, $platform); } } diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php index bada6d98bb..5dd5c6dcfa 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Detections/Create.php @@ -82,12 +82,11 @@ class Create extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_DETECTION_RUNTIME, + model: [ + Response::MODEL_DETECTION_RUNTIME, + Response::MODEL_DETECTION_FRAMEWORK, + ], ), - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_DETECTION_FRAMEWORK, - ) ] )) ->param('installationId', '', new Text(256), 'Installation Id') diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php index 9e32ca8276..52b94cd525 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/Get.php @@ -85,14 +85,16 @@ class Get extends Action $repository = $github->getRepository($owner, $repositoryName); - $authorized = false; - try { - $installationRepository = $github->getInstallationRepository($repositoryName); - if (!empty($installationRepository)) { - $authorized = true; + $authorized = $github->hasAccessToAllRepositories(); + if (!$authorized) { + try { + $installationRepository = $github->getInstallationRepository($repositoryName); + if (!empty($installationRepository)) { + $authorized = true; + } + } catch (RepositoryNotFound $e) { + $authorized = false; } - } catch (RepositoryNotFound $e) { - $authorized = false; } $repository['id'] = \strval($repository['id']) ?? ''; diff --git a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php index 53713f8407..d5b2b48175 100644 --- a/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php +++ b/src/Appwrite/Platform/Modules/VCS/Http/Installations/Repositories/XList.php @@ -86,12 +86,11 @@ class XList extends Action responses: [ new SDKResponse( code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + model: [ + Response::MODEL_PROVIDER_REPOSITORY_RUNTIME_LIST, + Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, + ], ), - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROVIDER_REPOSITORY_FRAMEWORK_LIST, - ) ] )) ->param('installationId', '', new Text(256), 'Installation Id') @@ -141,7 +140,7 @@ class XList extends Action $page = ($offset / $limit) + 1; $owner = $github->getOwnerName($providerInstallationId); - ['items' => $repos, 'total' => $total] = $github->searchRepositories($providerInstallationId, $owner, $page, $limit, $search); + ['items' => $repos, 'total' => $total] = $github->searchRepositories($owner, $page, $limit, $search); $repos = \array_map(function ($repo) use ($installation) { $repo['id'] = \strval($repo['id'] ?? ''); diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index af768444f2..79a052b34a 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -7,6 +7,7 @@ use Appwrite\Docker\Env; use Appwrite\Platform\Installer\Runtime\State; use Appwrite\Platform\Installer\Server as InstallerServer; use Appwrite\Utopia\View; +use Swoole\Coroutine; use Utopia\Auth\Proofs\Password; use Utopia\Auth\Proofs\Token; use Utopia\Config\Config; @@ -25,6 +26,7 @@ class Install extends Action private const int HEALTH_CHECK_ATTEMPTS = 30; private const int HEALTH_CHECK_DELAY_SECONDS = 1; + private const int PROC_CLOSE_TIMEOUT_SECONDS = 60; private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/'; private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/'; @@ -34,6 +36,7 @@ class Install extends Action private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; protected bool $isUpgrade = false; + protected bool $migrate = false; protected string $hostPath = ''; protected ?bool $isLocalInstall = null; protected ?array $installerConfig = null; @@ -169,9 +172,9 @@ class Install extends Action } } - // Block database type changes on existing installations. - // Only enforce if the existing config explicitly set _APP_DB_ADAPTER - // (pre-1.9.0 installs never had this variable). + // Detect database type from existing installation. + // 1.9.0+ installs have _APP_DB_ADAPTER; pre-1.9.0 installs + // can be detected by the DB service name or _APP_DB_HOST. $existingDatabase = null; foreach ($compose->getServices() as $service) { if (!$service) { @@ -190,10 +193,15 @@ class Install extends Action $existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null; } } - if ($existingDatabase !== null && $existingDatabase !== $database) { - Console::error("Cannot change database type from '{$existingDatabase}' to '{$database}'."); - Console::error('Changing database types on an existing installation is not supported.'); - Console::exit(1); + if ($existingDatabase === null) { + $existingDatabase = $this->detectDatabaseFromCompose($compose); + } + if ($existingDatabase !== null) { + if ($existingDatabase !== $database) { + $database = $existingDatabase; + Console::info("Detected existing database: {$database}"); + } + $vars['_APP_DB_ADAPTER']['default'] = $database; } } @@ -205,12 +213,14 @@ class Install extends Action } // If interactive and web mode enabled, start web server - if ($interactive === 'Y' && Console::isInteractive()) { + // Skip the web installer when explicit CLI params are provided + if ($interactive === 'Y' && Console::isInteractive() && !$this->hasExplicitCliParams()) { Console::success('Starting web installer...'); Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT); Console::info('Press Ctrl+C to cancel installation'); - $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars); + $detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null; + $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade || $existingInstallation, $detectedDb); return; } @@ -314,7 +324,7 @@ class Install extends Action $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade; $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets); - $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade); + $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade, migrate: $this->migrate); } @@ -503,7 +513,9 @@ class Install extends Action ?callable $progress = null, ?string $resumeFromStep = null, bool $isUpgrade = false, - array $account = [] + array $account = [], + ?callable $onComplete = null, + bool $migrate = false, ): void { $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, false); @@ -599,7 +611,12 @@ class Install extends Action if (!$noStart && $startIndex <= 2) { $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS; $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages); - $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI); + $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade); + + if (!$isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + $this->updateProgress($progress, InstallerServer::STEP_ACCOUNT_SETUP, InstallerServer::STATUS_IN_PROGRESS, messageOverride: 'Creating Appwrite account...'); + } if (!$isLocalInstall) { $this->connectInstallerToAppwriteNetwork(); @@ -607,17 +624,48 @@ class Install extends Action $domain = $input['_APP_DOMAIN'] ?? 'localhost'; - // Wait for Appwrite API to be healthy before marking containers as ready - $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS); + $healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP; + if (!$isUpgrade) { + $currentStep = InstallerServer::STEP_ACCOUNT_SETUP; + } + $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep); - $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + if ($isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages); + } if (!$isUpgrade) { $this->createInitialAdminAccount($account, $progress, $apiUrl, $domain); } - // Track installs - $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + if ($isUpgrade && $migrate) { + // Allow the containers-completed SSE event to flush + // before blocking on migration exec + usleep(200_000); + $currentStep = InstallerServer::STEP_MIGRATION; + $this->runDatabaseMigration($progress, $isLocalInstall); + } elseif ($isUpgrade) { + $this->updateProgress($progress, InstallerServer::STEP_MIGRATION, InstallerServer::STATUS_COMPLETED, messageOverride: 'Migration skipped'); + } + + // Signal completion before tracking so the SSE stream + // finishes and the frontend can redirect immediately. + if ($onComplete) { + try { + $onComplete(); + } catch (\Throwable) { + } + } + + // Run tracking in a coroutine when inside a Swoole + // request so it doesn't block the worker. + if (Coroutine::getCid() !== -1) { + go(function () use ($input, $isUpgrade, $version, $account) { + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + }); + } else { + $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account); + } if ($isCLI) { Console::success('Appwrite installed successfully'); @@ -658,8 +706,9 @@ class Install extends Action messageOverride: 'Creating Appwrite account' ); - // Create the account — tolerate "already exists" so we can still - // create a session (common when re-running the installer). + // Create the account — tolerate "already exists" and "console + // is restricted" errors so we can still create a session + // (common when re-running the installer or upgrading). $userId = null; try { $userId = $this->makeApiCall('/v1/account', [ @@ -669,7 +718,10 @@ class Install extends Action 'name' => $name ], false, $apiUrl, $domain); } catch (\Throwable $e) { - if (\stripos($e->getMessage(), 'already exists') === false) { + $message = $e->getMessage(); + $accountExists = \stripos($message, 'already exists') !== false + || \stripos($message, 'console is restricted') !== false; + if (!$accountExists) { throw $e; } } @@ -705,6 +757,46 @@ class Install extends Action } } + private function runDatabaseMigration(?callable $progress, bool $isLocalInstall): void + { + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_IN_PROGRESS, + messageOverride: 'Running database migration...' + ); + + // Allow the SSE chunk to flush before the blocking exec + usleep(100_000); + + // Static command — no user input involved + $command = $isLocalInstall + ? 'docker compose exec appwrite migrate 2>&1' + : 'docker exec appwrite migrate 2>&1'; + + $output = []; + \exec($command, $output, $exit); + + if ($exit !== 0) { + $message = trim(implode("\n", $output)); + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_ERROR, + details: ['output' => $message], + messageOverride: 'Migration failed: ' . ($message ?: 'exit code ' . $exit) + ); + throw new \RuntimeException('Database migration failed', 0, $message !== '' ? new \RuntimeException($message) : null); + } + + $this->updateProgress( + $progress, + InstallerServer::STEP_MIGRATION, + InstallerServer::STATUS_COMPLETED, + messageOverride: 'Database migration completed' + ); + } + private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void { if ($this->isLocalInstall()) { @@ -732,6 +824,8 @@ class Install extends Action $name = $account['name'] ?? 'Admin'; $email = $account['email'] ?? 'admin@selfhosted.local'; + $hostIp = @gethostbyname($domain); + $payload = [ 'action' => $type, 'account' => 'self-hosted', @@ -744,12 +838,19 @@ class Install extends Action 'email' => $email, 'domain' => $domain, 'database' => $database, + 'ip' => ($hostIp !== false && $hostIp !== $domain) ? $hostIp : null, + 'os' => php_uname('s') . ' ' . php_uname('r'), + 'arch' => php_uname('m'), + 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null, + 'ram' => (int) round(((float) trim((string) \shell_exec('grep MemTotal /proc/meminfo | awk \'{print $2}\''))) / 1024), ]), ]; try { $client = new Client(); $client + ->setConnectTimeout(5000) + ->setTimeout(5000) ->addHeader('Content-Type', 'application/json') ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload); } catch (\Throwable) { @@ -766,7 +867,7 @@ class Install extends Action * - host.docker.internal:{port} — reaches host-published ports from inside a container * - localhost:{port} — works when running directly on the host (local dev) */ - private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string + private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string { $client = new Client(); $client @@ -776,12 +877,16 @@ class Install extends Action $healthPath = '/v1/health/version'; - // Local dev: reach Traefik via localhost on the host. - // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed). - $candidate = $isLocalInstall - ? 'http://localhost:' . $httpPort . $healthPath - : self::APPWRITE_API_URL . $healthPath; - $candidates = [$candidate]; + if ($isLocalInstall) { + $candidates = [ + 'http://localhost:' . $httpPort . $healthPath, + ]; + } else { + $candidates = [ + self::APPWRITE_API_URL . $healthPath, + 'http://host.docker.internal:' . $httpPort . $healthPath, + ]; + } $lastErrors = []; @@ -803,7 +908,7 @@ class Install extends Action $progress( $step, InstallerServer::STATUS_IN_PROGRESS, - 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')', + 'Waiting for Appwrite to be ready...', [] ); } catch (\Throwable) { @@ -964,7 +1069,7 @@ class Install extends Action } } - protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void + protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI, ?callable $progress = null, bool $isUpgrade = false): void { $env = ''; if (!$useExistingConfig) { @@ -1004,8 +1109,28 @@ class Install extends Action $command[] = '-d'; $command[] = '--remove-orphans'; $command[] = '--renew-anon-volumes'; - $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1'; - \exec($commandLine, $output, $exit); + $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)); + + if ($progress) { + $totalServices = $this->countComposeServices($composeFile); + if ($totalServices > 0) { + $verb = $isUpgrade ? 'Restarting' : 'Starting'; + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + "$verb Docker containers...", + ['containerStarted' => 0, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } + $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade); + $output = $result['output']; + $exit = $result['exit']; + } else { + \exec($commandLine . ' 2>&1', $output, $exit); + } if ($exit !== 0) { $message = trim(implode("\n", $output)); @@ -1017,6 +1142,126 @@ class Install extends Action } } + private function countComposeServices(string $composeFile): int + { + $content = @file_get_contents($composeFile); + if ($content === false) { + return 0; + } + $count = preg_match_all('/^\s*container_name:/m', $content); + return $count !== false ? $count : 0; + } + + private function execWithContainerProgress(string $commandLine, int $totalServices, callable $progress, bool $isUpgrade): array + { + $verb = $isUpgrade ? 'Restarting' : 'Starting'; + $message = "$verb Docker containers..."; + $started = 0; + $output = []; + + $process = proc_open( + $commandLine . ' 2>&1', + [1 => ['pipe', 'w']], + $pipes + ); + + if (!is_resource($process)) { + return ['output' => [], 'exit' => 1]; + } + + stream_set_blocking($pipes[1], false); + $deadline = time() + self::PROC_CLOSE_TIMEOUT_SECONDS; + $buffer = ''; + + while (time() < $deadline) { + $status = proc_get_status($process); + + $read = [$pipes[1]]; + $write = null; + $except = null; + $changed = @stream_select($read, $write, $except, 1); + + if ($changed > 0) { + $chunk = fread($pipes[1], 8192); + if ($chunk === false || $chunk === '') { + if (!$status['running']) { + break; + } + continue; + } + $buffer .= $chunk; + while (($pos = strpos($buffer, "\n")) !== false) { + $trimmed = rtrim(substr($buffer, 0, $pos), "\r"); + $buffer = substr($buffer, $pos + 1); + $output[] = $trimmed; + + if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) { + $started = min($started + 1, $totalServices); + if ($totalServices > 0) { + try { + $progress( + InstallerServer::STEP_DOCKER_CONTAINERS, + InstallerServer::STATUS_IN_PROGRESS, + $message, + ['containerStarted' => $started, 'containerTotal' => $totalServices] + ); + } catch (\Throwable) { + } + } + } + } + } + + if (!$status['running'] && ($changed === 0 || feof($pipes[1]))) { + break; + } + } + + if ($buffer !== '') { + $output[] = rtrim($buffer, "\r\n"); + } + + fclose($pipes[1]); + + $exit = $this->procCloseWithTimeout($process, self::PROC_CLOSE_TIMEOUT_SECONDS); + + return ['output' => $output, 'exit' => $exit]; + } + + /** + * Wait up to $timeoutSeconds for a process to exit, then kill it. + * + * proc_close() blocks indefinitely which can hang the installer if + * docker compose refuses to exit after all containers are running. + * + * @param resource $process A process resource from proc_open() + */ + private function procCloseWithTimeout($process, int $timeoutSeconds): int + { + $deadline = time() + $timeoutSeconds; + + while (time() < $deadline) { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + $closeCode = proc_close($process); + return $exitCode !== -1 ? $exitCode : $closeCode; + } + usleep(250_000); + } + + proc_terminate($process, SIGTERM); + usleep(500_000); + + if (proc_get_status($process)['running']) { + proc_terminate($process, SIGKILL); + } + + proc_close($process); + + return 124; + } + protected function isLocalInstall(): bool { if ($this->isLocalInstall === null) { @@ -1089,6 +1334,50 @@ class Install extends Action $this->hostPath = $this->getInstallerHostPath(); } + /** + * Check if any installer-specific CLI params were explicitly passed. + * When params like --database or --http-port are provided, the user + * intends to run in CLI mode rather than launching the web installer. + */ + private function hasExplicitCliParams(): bool + { + $argv = $_SERVER['argv'] ?? []; + foreach ($argv as $arg) { + if (\str_starts_with($arg, '--')) { + return true; + } + } + return false; + } + + /** + * Detect the database adapter from a pre-1.9.0 compose file by + * checking which DB service exists or reading _APP_DB_HOST. + */ + private function detectDatabaseFromCompose(Compose $compose): ?string + { + $serviceNames = array_keys($compose->getServices()); + $dbServices = ['mariadb', 'mongodb', 'postgresql']; + foreach ($dbServices as $db) { + if (in_array($db, $serviceNames, true)) { + return $db; + } + } + + foreach ($compose->getServices() as $service) { + if (!$service) { + continue; + } + $env = $service->getEnvironment()->list(); + $host = $env['_APP_DB_HOST'] ?? null; + if ($host !== null && in_array($host, $dbServices, true)) { + return $host; + } + } + + return null; + } + protected function readExistingCompose(): string { $composeFile = $this->path . '/' . $this->getComposeFileName(); diff --git a/src/Appwrite/Platform/Tasks/SDKs.php b/src/Appwrite/Platform/Tasks/SDKs.php index 528084f4ea..a36959af33 100644 --- a/src/Appwrite/Platform/Tasks/SDKs.php +++ b/src/Appwrite/Platform/Tasks/SDKs.php @@ -14,16 +14,17 @@ use Appwrite\SDK\Language\Flutter; use Appwrite\SDK\Language\Go; use Appwrite\SDK\Language\GraphQL; use Appwrite\SDK\Language\Kotlin; -use Appwrite\SDK\Language\Markdown; use Appwrite\SDK\Language\Node; use Appwrite\SDK\Language\PHP; use Appwrite\SDK\Language\Python; use Appwrite\SDK\Language\ReactNative; use Appwrite\SDK\Language\REST; use Appwrite\SDK\Language\Ruby; +use Appwrite\SDK\Language\Rust; use Appwrite\SDK\Language\Swift; use Appwrite\SDK\Language\Web; use Appwrite\SDK\SDK; +use Appwrite\Spec\StaticSpec; use Appwrite\Spec\Swagger2; use CzProject\GitPhp\Git; use Utopia\Agents\Adapters\OpenAI; @@ -49,7 +50,10 @@ class SDKs extends Action public static function getPlatforms(): array { - return Specs::getPlatforms(); + return [ + ...Specs::getPlatforms(), + APP_SDK_PLATFORM_STATIC, + ]; } protected function getSdkConfigPath(): string @@ -89,7 +93,7 @@ class SDKs extends Action $selectedSDK = $sdk; if (! $sdks) { - $selectedPlatform ??= Console::confirm('Choose Platform ("' . implode('", "', static::getPlatforms()) . '" or "*" for all):'); + $selectedPlatform ??= Console::confirm('Choose Platform ("' . implode('", "', static::getPlatforms()) . '", comma-separated, or "*" for all):'); $selectedSDK ??= \strtolower(Console::confirm('Choose SDK ("*" for all):')); $supportedSDKs = $this->getSupportedSDKs(); if ($selectedSDK !== '*' && ! \in_array($selectedSDK, $supportedSDKs)) { @@ -98,11 +102,14 @@ class SDKs extends Action } else { $sdks = explode(',', $sdks); } - $version ??= Console::confirm('Choose an Appwrite version'); $createRelease = ($release === 'yes'); $commitRelease = ($commit === 'yes'); + if ($createRelease && $examplesOnly) { + throw new \Exception('Cannot use --release=yes with --mode=examples'); + } + if (! $createRelease && ! $examplesOnly) { $git ??= Console::confirm('Should we use git push? (yes/no)'); $git = ($git === 'yes'); @@ -114,34 +121,50 @@ class SDKs extends Action $prUrls = []; } - if (! \in_array($version, [ - '0.6.x', - '0.7.x', - '0.8.x', - '0.9.x', - '0.10.x', - '0.11.x', - '0.12.x', - '0.13.x', - '0.14.x', - '0.15.x', - '1.0.x', - '1.1.x', - '1.2.x', - '1.3.x', - '1.4.x', - '1.5.x', - '1.6.x', - '1.7.x', - '1.8.x', - 'latest', - ])) { - throw new \Exception('Unknown version given'); + if (! $createRelease) { + $version ??= Console::confirm('Choose an Appwrite version'); + + if (! \in_array($version, [ + '0.6.x', + '0.7.x', + '0.8.x', + '0.9.x', + '0.10.x', + '0.11.x', + '0.12.x', + '0.13.x', + '0.14.x', + '0.15.x', + '1.0.x', + '1.1.x', + '1.2.x', + '1.3.x', + '1.4.x', + '1.5.x', + '1.6.x', + '1.7.x', + '1.8.x', + '1.9.x', + 'latest', + ])) { + throw new \Exception('Unknown version given'); + } + } + + $selectedPlatforms = ($selectedPlatform === '*' || $selectedPlatform === null) ? null : \array_map('trim', \explode(',', $selectedPlatform)); + + if ($selectedPlatforms !== null) { + $validPlatforms = static::getPlatforms(); + foreach ($selectedPlatforms as $p) { + if (! \in_array($p, $validPlatforms)) { + throw new \Exception('Unknown platform "' . $p . '". Options are: ' . implode(', ', $validPlatforms)); + } + } } $platforms = Config::getParam('sdks'); foreach ($platforms as $key => $platform) { - if ($selectedPlatform !== $key && $selectedPlatform !== '*' && ($sdks === null)) { + if ($selectedPlatforms !== null && ! \in_array($key, $selectedPlatforms) && ($sdks === null)) { continue; } @@ -151,20 +174,146 @@ class SDKs extends Action } if (! $language['enabled']) { - Console::warning($language['name'] . ' for ' . $platform['name'] . ' is disabled'); + Console::warning("{$language['name']} for {$platform['name']} is disabled"); continue; } - Console::info('Fetching API Spec for ' . $language['name'] . ' for ' . $platform['name'] . ' (version: ' . $version . ')'); + Console::log(''); - $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json'; + if ($createRelease && ! $examplesOnly) { + Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$language['version']}) ━━━"); + $changelog = $language['changelog'] ?? ''; + $changelog = ($changelog) ? \file_get_contents($changelog) : '# Change Log'; - if (!file_exists($specPath)) { - throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.'); + $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; + $releaseVersion = $language['version']; + $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion); + + if (empty($releaseNotes)) { + $releaseNotes = "Release version {$releaseVersion}"; + } + + $releaseTitle = $releaseVersion; + $releaseTarget = $language['repoBranch'] ?? 'main'; + + if ($repoName === '/') { + Console::warning(' Not a releasable SDK, skipping'); + + continue; + } + + // Check if release already exists + $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null'; + $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? ''); + + if (! empty($existingReleaseUrl)) { + Console::warning(" Release {$releaseVersion} already exists, skipping"); + Console::log(" {$existingReleaseUrl}"); + + continue; + } + + // Check if the latest commit on the target branch already has a release + $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null'; + $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? ''); + + if (! empty($latestCommitSha)) { + $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null'; + $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? ''); + + if (! empty($latestReleaseTag)) { + $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null'; + $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? ''); + + if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) { + Console::warning(" Latest commit already released ({$latestReleaseTag}), skipping"); + + continue; + } + } + } + + $previousVersion = ''; + $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; + $previousVersion = trim(\shell_exec($tagListCommand) ?? ''); + + $formattedNotes = "## What's Changed\n\n"; + $formattedNotes .= $releaseNotes . "\n\n"; + + if (! empty($previousVersion)) { + $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion; + } else { + $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion; + } + + if (! $commitRelease) { + Console::info(' [DRY RUN] Would create release:'); + Console::log(" Repository: {$repoName}"); + Console::log(" Version: {$releaseVersion}"); + Console::log(" Title: {$releaseTitle}"); + Console::log(" Target Branch: {$releaseTarget}"); + Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A')); + Console::log(' Release Notes:'); + Console::log(' ' . str_replace("\n", "\n ", $formattedNotes)); + } else { + Console::log(" Creating release {$releaseVersion}..."); + + $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_'); + \file_put_contents($tempNotesFile, $formattedNotes); + + $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \ + --repo ' . \escapeshellarg($repoName) . ' \ + --title ' . \escapeshellarg($releaseTitle) . ' \ + --notes-file ' . \escapeshellarg($tempNotesFile) . ' \ + --target ' . \escapeshellarg($releaseTarget) . ' \ + 2>&1'; + + $releaseOutput = []; + $releaseReturnCode = 0; + \exec($releaseCommand, $releaseOutput, $releaseReturnCode); + + \unlink($tempNotesFile); + + if ($releaseReturnCode === 0) { + // Extract release URL from output + $releaseUrl = ''; + foreach ($releaseOutput as $line) { + if (strpos($line, 'https://github.com/') !== false) { + $releaseUrl = trim($line); + break; + } + } + + Console::success(" Release {$releaseVersion} created"); + if (! empty($releaseUrl)) { + Console::log(" {$releaseUrl}"); + } + } else { + $errorMessage = implode("\n", $releaseOutput); + Console::error(" Failed to create release: " . $errorMessage); + } + } + + continue; } - $spec = file_get_contents($specPath); + Console::info("━━━ {$language['name']} SDK ({$platform['name']}, {$version}) ━━━"); + $specFormat = $language['spec'] ?? 'swagger2'; + $spec = null; + if ($specFormat === 'static') { + Console::log(' Using static SDK spec...'); + } else { + Console::log(' Fetching API spec...'); + + $specPath = __DIR__ . '/../../../../app/config/specs/swagger2-' . $version . '-' . $language['family'] . '.json'; + + if (!file_exists($specPath)) { + throw new \Exception('Spec file not found: ' . $specPath . '. Please run "docker compose exec appwrite specs --version=' . $version . '" first to generate the specs.'); + } + + $spec = file_get_contents($specPath); + } $cover = 'https://github.com/appwrite/appwrite/raw/main/public/images/github.png'; $result = \realpath(__DIR__ . '/../../../../app') . '/sdks/' . $key . '-' . $language['key']; @@ -287,16 +436,15 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $config = new Kotlin(); $warning = $warning . "\n\n > This is the Kotlin SDK for integrating with Appwrite from your Kotlin server-side code. If you're looking for the Android SDK you should check [appwrite/sdk-for-android](https://github.com/appwrite/sdk-for-android)"; break; + case 'rust': + $config = new Rust(); + break; case 'graphql': $config = new GraphQL(); break; case 'rest': $config = new REST(); break; - case 'markdown': - $config = new Markdown(); - $config->setNPMPackage('@appwrite.io/docs'); - break; case 'agent-skills': $config = new AgentSkills(); break; @@ -307,125 +455,22 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND throw new \Exception('Language "' . $language['key'] . '" not supported'); } - if ($createRelease && ! $examplesOnly) { - $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; - $releaseVersion = $language['version']; - $releaseNotes = $this->extractReleaseNotes($changelog, $releaseVersion); + Console::log($examplesOnly + ? ' Generating examples...' + : ' Generating SDK...'); - if (empty($releaseNotes)) { - $releaseNotes = "Release version {$releaseVersion}"; - } - - $releaseTitle = $releaseVersion; - $releaseTarget = $language['repoBranch'] ?? 'main'; - - if ($repoName === '/') { - Console::warning("{$language['name']} SDK is not an SDK, skipping release"); - - continue; - } - - // Check if release already exists - $checkReleaseCommand = 'gh release view ' . \escapeshellarg($releaseVersion) . ' --repo ' . \escapeshellarg($repoName) . ' --json url --jq ".url" 2>/dev/null'; - $existingReleaseUrl = trim(\shell_exec($checkReleaseCommand) ?? ''); - - if (! empty($existingReleaseUrl)) { - Console::warning("Release {$releaseVersion} already exists for {$language['name']} SDK, skipping..."); - Console::info("Existing release: {$existingReleaseUrl}"); - - continue; - } - - // Check if the latest commit on the target branch already has a release - $latestCommitCommand = 'gh api repos/' . $repoName . '/commits/' . $releaseTarget . ' --jq ".sha" 2>/dev/null'; - $latestCommitSha = trim(\shell_exec($latestCommitCommand) ?? ''); - - if (! empty($latestCommitSha)) { - $latestReleaseTagCommand = 'gh api repos/' . $repoName . '/releases --jq ".[0] | .tag_name" 2>/dev/null'; - $latestReleaseTag = trim(\shell_exec($latestReleaseTagCommand) ?? ''); - - if (! empty($latestReleaseTag)) { - $tagCommitCommand = 'gh api repos/' . $repoName . '/git/ref/tags/' . $latestReleaseTag . ' --jq ".object.sha" 2>/dev/null'; - $tagCommitSha = trim(\shell_exec($tagCommitCommand) ?? ''); - - if (! empty($tagCommitSha) && $latestCommitSha === $tagCommitSha) { - Console::warning("Latest commit on {$releaseTarget} already has a release ({$latestReleaseTag}) for {$language['name']} SDK, skipping to avoid empty release..."); - - continue; - } - } - } - - $previousVersion = ''; - $tagListCommand = 'gh release list --repo ' . \escapeshellarg($repoName) . ' --limit 1 --json tagName --jq ".[0].tagName" 2>&1'; - $previousVersion = trim(\shell_exec($tagListCommand) ?? ''); - - $formattedNotes = "## What's Changed\n\n"; - $formattedNotes .= $releaseNotes . "\n\n"; - - if (! empty($previousVersion)) { - $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/compare/' . $previousVersion . '...' . $releaseVersion; - } else { - $formattedNotes .= '**Full Changelog**: https://github.com/' . $repoName . '/releases/tag/' . $releaseVersion; - } - - if (! $commitRelease) { - Console::info("[DRY RUN] Would create release for {$language['name']} SDK:"); - Console::log(" Repository: {$repoName}"); - Console::log(" Version: {$releaseVersion}"); - Console::log(" Title: {$releaseTitle}"); - Console::log(" Target Branch: {$releaseTarget}"); - Console::log(' Previous Version: ' . ($previousVersion ?: 'N/A')); - Console::log(' Release Notes:'); - Console::log(' ' . str_replace("\n", "\n ", $formattedNotes)); - Console::log(''); - } else { - Console::info("Creating release {$releaseVersion} for {$language['name']} SDK..."); - - $tempNotesFile = \tempnam(\sys_get_temp_dir(), 'release_notes_'); - \file_put_contents($tempNotesFile, $formattedNotes); - - $releaseCommand = 'gh release create ' . \escapeshellarg($releaseVersion) . ' \ - --repo ' . \escapeshellarg($repoName) . ' \ - --title ' . \escapeshellarg($releaseTitle) . ' \ - --notes-file ' . \escapeshellarg($tempNotesFile) . ' \ - --target ' . \escapeshellarg($releaseTarget) . ' \ - 2>&1'; - - $releaseOutput = []; - $releaseReturnCode = 0; - \exec($releaseCommand, $releaseOutput, $releaseReturnCode); - - \unlink($tempNotesFile); - - if ($releaseReturnCode === 0) { - // Extract release URL from output - $releaseUrl = ''; - foreach ($releaseOutput as $line) { - if (strpos($line, 'https://github.com/') !== false) { - $releaseUrl = trim($line); - break; - } - } - - Console::success("Successfully created release {$releaseVersion} for {$language['name']} SDK"); - if (! empty($releaseUrl)) { - Console::info("Release URL: {$releaseUrl}"); - } - } else { - $errorMessage = implode("\n", $releaseOutput); - Console::error("Failed to create release for {$language['name']} SDK: " . $errorMessage); - } - } - - continue; - } - - Console::info($examplesOnly - ? "Generating examples for {$language['name']} SDK..." - : "Generating {$language['name']} SDK..."); - - $sdk = new SDK($config, new Swagger2($spec)); + $sdk = new SDK( + $config, + $specFormat === 'static' + ? new StaticSpec( + title: 'Appwrite', + description: 'Appwrite backend as a service', + version: $version, + licenseName: 'BSD-3-Clause', + licenseURL: 'https://raw.githubusercontent.com/appwrite/appwrite/master/LICENSE', + ) + : new Swagger2($spec) + ); $sdk ->setName($language['name']) @@ -466,6 +511,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND try { $sdk->generate($result); + Console::success($examplesOnly + ? " Examples generated at {$result}" + : " SDK generated at {$result}"); } catch (\Throwable $exception) { Console::error($exception->getMessage()); } @@ -477,11 +525,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $aiChangelog = ''; // Track AI-generated changelog for PR description if (! empty($apiKey) && ! $examplesOnly) { - Console::info("Analyzing SDK changes with AI..."); + Console::log(' Analyzing changes with AI...'); $aiResult = $this->generateVersionAndChangelog($language, $result); if (!empty($aiResult['skip'])) { - Console::warning("Skipping {$language['name']} SDK generation"); + Console::warning(' Skipping (no relevant changes)'); continue; } elseif ($aiResult !== null) { $newVersion = $aiResult['version']; @@ -509,7 +557,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND Console::error($exception->getMessage()); } } else { - Console::warning('AI analysis failed, using existing version'); + Console::warning(' AI analysis failed, using existing version'); } } @@ -530,10 +578,11 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $pushSuccess = $this->pushToGit($language, $target, $result, $gitUrl, $gitBranch, $repoBranch, $commitMessage); if ($pushSuccess) { - $this->createPullRequest($language, $target, $gitBranch, $repoBranch, $aiChangelog, $prUrls); + $this->createPullRequest($language, $platform['name'], $target, $gitBranch, $repoBranch, $aiChangelog, $prUrls); } - $this->cleanupTarget($target, $language['name']); + \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target); + Console::log(' Cleaned up temp directory'); } $this->copyExamples($language, $version, $result, $resultExamples); @@ -542,9 +591,13 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND if (! empty($prUrls)) { Console::log(''); - Console::log('Pull Request Summary'); - foreach ($prUrls as $sdkName => $url) { - Console::log("{$sdkName}: {$url}"); + Console::info('━━━ Pull Request Summary ━━━'); + foreach ($prUrls as $platformName => $sdks) { + Console::log(''); + Console::info(" {$platformName}:"); + foreach ($sdks as $sdkName => $url) { + Console::log(" {$sdkName}: {$url}"); + } } Console::log(''); } @@ -552,7 +605,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND private function pushToGit(array $language, string $target, string $result, string $gitUrl, string $gitBranch, string $repoBranch, string $commitMessage): bool { - Console::info("Preparing {$language['name']} SDK repository..."); + Console::log(' Preparing git repository...'); try { // Init fresh repo @@ -635,18 +688,26 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // Stage, commit, push $repo->addAllChanges(); - $repo->commit($commitMessage); + + try { + $repo->commit($commitMessage); + } catch (\Throwable $e) { + // Exit code 1 (256 in PHP) = nothing to commit + Console::log(' No changes to commit, SDK is up to date'); + return true; + } + $repo->execute('push', '-u', 'origin', $gitBranch, '--quiet'); } catch (\Throwable $e) { - Console::warning("Git operations failed for {$language['name']} SDK: " . $e->getMessage()); + Console::warning(" Git push failed: " . $e->getMessage()); return false; } - Console::success("Pushed {$language['name']} SDK to {$gitUrl}"); + Console::success(" Pushed to {$gitUrl}"); return true; } - private function createPullRequest(array $language, string $target, string $gitBranch, string $repoBranch, string $aiChangelog, array &$prUrls): void + private function createPullRequest(array $language, string $platformName, string $target, string $gitBranch, string $repoBranch, string $aiChangelog, array &$prUrls): void { $prTitle = "feat: {$language['name']} SDK update for version {$language['version']}"; $prBody = "This PR contains updates to the {$language['name']} SDK for version {$language['version']}."; @@ -655,7 +716,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND } $repoName = $language['gitUserName'] . '/' . $language['gitRepoName']; - Console::info("Creating pull request for {$language['name']} SDK..."); + Console::log(' Creating pull request...'); $prCommand = 'cd ' . $target . ' && \ gh pr create \ @@ -671,29 +732,32 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND \exec($prCommand, $prOutput, $prReturnCode); if ($prReturnCode === 0) { - Console::success("Successfully created pull request for {$language['name']} SDK"); + Console::success(" Pull request created"); foreach ($prOutput as $line) { if (\str_starts_with(trim($line), 'https://')) { - $prUrls[$language['name']] = trim($line); + $prUrls[$platformName][$language['name']] = trim($line); break; } } } else { $errorMessage = implode("\n", $prOutput); if (strpos($errorMessage, 'already exists') === false) { - Console::error("Failed to create pull request for {$language['name']} SDK: " . $errorMessage); + Console::error(" Failed to create pull request: " . $errorMessage); } else { - $this->updateExistingPr($target, $repoName, $gitBranch, $prTitle, $prBody, $language['name'], $prUrls); + // Extract PR URL from the error output (gh includes it in "already exists" messages) + $existingPrUrl = ''; + foreach ($prOutput as $line) { + if (\preg_match('#(https://github\.com/[^\s]+/pull/\d+)#', $line, $urlMatch)) { + $existingPrUrl = $urlMatch[1]; + break; + } + } + + $this->updateExistingPr($repoName, $gitBranch, $prTitle, $prBody, $platformName, $language['name'], $prUrls, $existingPrUrl); } } } - private function cleanupTarget(string $target, string $languageName): void - { - \exec('chmod -R u+w ' . $target . ' && rm -rf ' . $target); - Console::success("Remove temp directory '{$target}' for {$languageName} SDK"); - } - private function copyExamples(array $language, string $version, string $result, string $resultExamples): void { $docDirectories = $language['docDirectories'] ?? ['']; @@ -707,7 +771,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $examplesSource = $result . '/docs/examples' . $languagePath; if (! \is_dir($examplesSource)) { - Console::warning("No code examples found for {$language['name']} SDK at: {$examplesSource}. Skipping copy."); + Console::warning(" No code examples found at: {$examplesSource}"); continue; } @@ -716,7 +780,8 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 'mkdir -p ' . $resultExamples . $languagePath . ' && \ cp -r ' . $examplesSource . ' ' . $resultExamples ); - Console::success("Copied code examples for {$language['name']} SDK to: {$resultExamples}"); + $label = \is_string($languageTitle) ? " ({$languageTitle})" : ''; + Console::success(" Examples{$label} copied to {$resultExamples}{$languagePath}"); } } @@ -772,13 +837,13 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $repoBranch = $language['repoBranch'] ?? 'main'; if (empty($gitUrl)) { - Console::warning("No git URL for {$language['name']} SDK, skipping AI analysis"); + Console::warning(' No git URL, skipping AI analysis'); return null; } $apiKey = System::getEnv('_APP_ASSISTANT_OPENAI_API_KEY', ''); if (empty($apiKey)) { - Console::warning('_APP_ASSISTANT_OPENAI_API_KEY not set, cannot use AI for version analysis'); + Console::warning(' _APP_ASSISTANT_OPENAI_API_KEY not set, skipping AI analysis'); return null; } @@ -870,7 +935,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ->setMaxDiffLines(500) ->setUserId('sdk-analyst'); - Console::info("Running DiffCheck for {$language['name']} SDK..."); + Console::log(' Running DiffCheck...'); $result = (new DiffCheck())->run( runner: $adapter, @@ -881,43 +946,42 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ); if (!$result['hasChanges']) { - Console::info("✓ No changes detected - SDK is up to date"); + Console::success(' No changes detected, SDK is up to date'); return null; } $responseContent = $result['response']; if (empty(trim($responseContent))) { - Console::warning('AI returned empty response'); + Console::warning(' AI returned empty response'); return null; } $parsed = json_decode($responseContent, true); if (json_last_error() !== JSON_ERROR_NONE) { - Console::warning('Failed to parse AI response as JSON: ' . json_last_error_msg()); - Console::log('Raw response:'); - Console::log($responseContent); + Console::warning(' Failed to parse AI response: ' . json_last_error_msg()); + Console::log(' Raw response: ' . $responseContent); return null; } if (empty($parsed['version']) || empty($parsed['changelog']) || empty($parsed['versionBump'])) { - Console::warning('AI response missing required fields'); + Console::warning(' AI response missing required fields'); return null; } // Guard: beta SDKs must not be bumped to >= 1.0.0 if ($isBeta && ($parsed['versionBump'] === 'major' || \version_compare($parsed['version'], '1.0.0', '>='))) { - Console::warning("Beta SDK {$language['name']} cannot have a major bump or version >= 1.0.0 (AI suggested {$parsed['version']}), skipping"); + Console::warning(" Beta SDK cannot bump to {$parsed['version']}, skipping"); return ['skip' => true]; } - Console::success("✓ Analysis complete"); - Console::log(" Version: {$language['version']} → {$parsed['version']} ({$parsed['versionBump']} bump)"); - Console::log(" Changelog:"); + Console::success(" AI analysis complete"); + Console::log(" Version: {$language['version']} → {$parsed['version']} ({$parsed['versionBump']})"); + Console::log(" Changelog:"); foreach (explode("\n", $parsed['changelog']) as $line) { if (trim($line)) { - Console::log(" {$line}"); + Console::log(" {$line}"); } } @@ -927,7 +991,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 'versionBump' => $parsed['versionBump'], ]; } catch (\Throwable $e) { - Console::error('Error generating version and changelog: ' . $e->getMessage()); + Console::error(' AI error: ' . $e->getMessage()); return null; } } @@ -945,7 +1009,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $configPath = $this->getSdkConfigPath(); if (! file_exists($configPath)) { - Console::error("Config file not found: {$configPath}"); + Console::error(" Config file not found: {$configPath}"); return false; } @@ -960,10 +1024,10 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $newContent = preg_replace($inlinePattern, '${1}' . $newVersion . '${3}', $content); if (file_put_contents($configPath, $newContent) !== false) { - Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config"); + Console::success(" Config updated: {$sdkKey} {$oldVersion} → {$newVersion}"); return true; } else { - Console::error('Failed to write config file'); + Console::error(' Failed to write config file'); return false; } } @@ -971,24 +1035,45 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // Second, try to find version in array format (pattern 2) // Pattern matches: 'nodejs' => '22.1.2', or "nodejs" => "22.1.2", // Also handles extra whitespace: 'nodejs' => '22.1.2', - $arrayPattern = '/([\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"],)/m'; + // Scoped to the correct $Versions array block to avoid + // updating duplicate keys that appear under a different platform. + $blockPattern = '/(\$' . preg_quote($platform, '/') . 'Versions\s*=\s*\[)([\s\S]*?)(\];)/m'; + $entryPattern = '/([\'"]' . preg_quote($sdkKey, '/') . '[\'"]\s*=>\s*[\'"])([^\'"]+)([\'"],?)/m'; - if (preg_match($arrayPattern, $content, $matches)) { - $oldVersion = $matches[2]; - $newContent = preg_replace($arrayPattern, '${1}' . $newVersion . '${3}', $content); - - if (file_put_contents($configPath, $newContent) !== false) { - Console::success("Updated {$sdkKey} version from {$oldVersion} to {$newVersion} in config"); - return true; - } else { - Console::error('Failed to write config file'); - return false; - } + if (! preg_match($blockPattern, $content)) { + Console::warning(" Could not find \${$platform}Versions block in config file"); + return false; } - Console::warning("Could not find version entry for {$sdkKey} in config"); + $updated = false; + $oldVersion = ''; + $newContent = preg_replace_callback($blockPattern, function ($blockMatch) use ($entryPattern, $newVersion, &$updated, &$oldVersion) { + $blockContent = $blockMatch[2]; + if (preg_match($entryPattern, $blockContent, $entryMatch)) { + $oldVersion = $entryMatch[2]; + $blockContent = preg_replace($entryPattern, '${1}' . $newVersion . '${3}', $blockContent); + $updated = true; + } + return $blockMatch[1] . $blockContent . $blockMatch[3]; + }, $content); - return false; + if ($newContent === null) { + Console::error(' preg_replace_callback failed while updating config'); + return false; + } + + if (! $updated) { + Console::warning(" Could not find version entry for {$sdkKey} in \${$platform}Versions block"); + return false; + } + + if (file_put_contents($configPath, $newContent) === false) { + Console::error(' Failed to write config file'); + return false; + } + + Console::success(" Config updated: {$sdkKey} {$oldVersion} → {$newVersion}"); + return true; } /** @@ -1002,7 +1087,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND private function updateChangelogFile(string $changelogPath, string $version, string $notes): bool { if (empty($changelogPath) || ! file_exists($changelogPath)) { - Console::warning("Changelog file not found: {$changelogPath}"); + Console::warning(" Changelog file not found: {$changelogPath}"); return false; } @@ -1011,7 +1096,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // Check if version already exists if (strpos($content, "## {$version}") !== false) { - Console::warning("Version {$version} already exists in changelog, skipping update"); + Console::warning(" Version {$version} already in changelog, skipping"); return false; } @@ -1039,72 +1124,74 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND $newContent = implode("\n", $newLines); if (file_put_contents($changelogPath, $newContent) !== false) { - Console::success("Updated changelog at {$changelogPath} with version {$version}"); + Console::success(" Changelog updated with version {$version}"); return true; } else { - Console::error('Failed to write changelog file'); + Console::error(' Failed to write changelog file'); return false; } } - private function updateExistingPr(string $target, string $repoName, string $gitBranch, string $prTitle, string $prBody, string $sdkName, array &$prUrls): void + private function updateExistingPr(string $repoName, string $gitBranch, string $prTitle, string $prBody, string $platformName, string $sdkName, array &$prUrls, string $existingPrUrl = ''): void { - Console::warning("Pull request already exists for {$sdkName} SDK, updating title and body..."); + Console::log(' Pull request already exists, updating...'); - $prNumberCommand = 'cd ' . $target . ' && \ - gh pr list \ - --repo ' . \escapeshellarg($repoName) . ' \ - --head ' . \escapeshellarg($gitBranch) . ' \ - --json number \ - --jq ".[0].number" \ - 2>&1'; + $prNumber = ''; + $prUrl = ''; - $prNumberOutput = []; - $prNumberReturnCode = 0; - \exec($prNumberCommand, $prNumberOutput, $prNumberReturnCode); + // Try extracting from the gh pr create error output first (free, no API call) + if (! empty($existingPrUrl) && \preg_match('#/pull/(\d+)#', $existingPrUrl, $matches)) { + $prNumber = $matches[1]; + $prUrl = $existingPrUrl; + } - if ($prNumberReturnCode !== 0 || empty($prNumberOutput[0])) { - Console::error("Failed to get PR number for {$sdkName} SDK"); + // Otherwise, look it up via gh pr list + if (empty($prNumber)) { + $prListCommand = 'gh pr list' + . ' --repo ' . \escapeshellarg($repoName) + . ' --head ' . \escapeshellarg($gitBranch) + . ' --json number,url' + . ' --jq ".[0] | (.number|tostring) + \" \" + .url"' + . ' 2>&1'; + + $prListOutput = []; + \exec($prListCommand, $prListOutput); + + if (! empty($prListOutput[0])) { + $parts = \explode(' ', trim($prListOutput[0]), 2); + $prNumber = $parts[0] ?? ''; + $prUrl = $parts[1] ?? ''; + } + } + + if (empty($prNumber)) { + Console::error(" Failed to find existing PR for branch {$gitBranch}"); return; } - $prNumber = trim($prNumberOutput[0]); $apiPath = "/repos/{$repoName}/pulls/{$prNumber}"; - $updateCommand = 'cd ' . $target . ' && \ - gh api \ - --method PATCH \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - ' . \escapeshellarg($apiPath) . ' \ - -f title=' . \escapeshellarg($prTitle) . ' \ - -f body=' . \escapeshellarg($prBody) . ' \ - 2>&1'; + $updateCommand = 'gh api' + . ' --method PATCH' + . ' -H "Accept: application/vnd.github+json"' + . ' -H "X-GitHub-Api-Version: 2022-11-28"' + . ' ' . \escapeshellarg($apiPath) + . ' -f title=' . \escapeshellarg($prTitle) + . ' -f body=' . \escapeshellarg($prBody) + . ' 2>&1'; $updateOutput = []; $updateReturnCode = 0; \exec($updateCommand, $updateOutput, $updateReturnCode); if ($updateReturnCode !== 0) { - Console::error("Failed to update pull request for {$sdkName} SDK: " . implode("\n", $updateOutput)); + Console::error(" Failed to update pull request: " . implode("\n", $updateOutput)); return; } - Console::success("Successfully updated pull request for {$sdkName} SDK"); + Console::success(" Pull request updated"); - $prUrlCommand = 'cd ' . $target . ' && \ - gh pr list \ - --repo ' . \escapeshellarg($repoName) . ' \ - --head ' . \escapeshellarg($gitBranch) . ' \ - --json url \ - --jq ".[0].url" \ - 2>&1'; - - $prUrlOutput = []; - $prUrlReturnCode = 0; - \exec($prUrlCommand, $prUrlOutput, $prUrlReturnCode); - - if ($prUrlReturnCode === 0 && ! empty($prUrlOutput)) { - $prUrls[$sdkName] = trim($prUrlOutput[0]); + if (! empty($prUrl)) { + $prUrls[$platformName][$sdkName] = $prUrl; } } } diff --git a/src/Appwrite/Platform/Tasks/Specs.php b/src/Appwrite/Platform/Tasks/Specs.php index 606c03bf10..a6a5284fb0 100644 --- a/src/Appwrite/Platform/Tasks/Specs.php +++ b/src/Appwrite/Platform/Tasks/Specs.php @@ -347,6 +347,13 @@ class Specs extends Action $keys = $this->getKeys(); $generatedFiles = []; + $endpoint = System::getEnv('_APP_HOME', 'https://appwrite.io'); + $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', 'team@appwrite.io'); + $specsDir = __DIR__ . '/../../../../app/config/specs'; + + if (!is_dir($specsDir) && !@mkdir($specsDir, 0755, true) && !is_dir($specsDir)) { + throw new Exception('Failed to create specs directory: ' . $specsDir); + } foreach ($platforms as $platform) { $routes = []; @@ -443,8 +450,6 @@ class Specs extends Action foreach (['swagger2', 'open-api3'] as $format) { $formatInstance = $this->getFormatInstance($format, $arguments); $specs = new Specification($formatInstance); - $endpoint = System::getEnv('_APP_HOME', '[HOSTNAME]'); - $email = System::getEnv('_APP_SYSTEM_TEAM_EMAIL', APP_EMAIL_TEAM); $formatInstance ->setParam('name', APP_NAME) @@ -463,36 +468,30 @@ class Specs extends Action ->setParam('docs.description', 'Full API docs, specs and tutorials') ->setParam('docs.url', $endpoint . '/docs'); - $specsDir = __DIR__ . '/../../../../app/config/specs'; + $path = $mocks + ? $specsDir . '/' . $format . '-mocks-' . $platform . '.json' + : $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - if (!is_dir($specsDir)) { - if (!mkdir($specsDir, 0755, true)) { - throw new Exception('Failed to create specs directory: ' . $specsDir); - } + $parsedSpecs = $specs->parse(); + $encodedSpecs = \json_encode($parsedSpecs, JSON_PRETTY_PRINT); + + unset($parsedSpecs); + + if ($encodedSpecs === false) { + throw new Exception('Failed to encode ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . \json_last_error_msg()); } - if ($mocks) { - $path = $specsDir . '/' . $format . '-mocks-' . $platform . '.json'; - - if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) { - throw new Exception('Failed to save mocks spec file: ' . $path); - } - - $generatedFiles[] = realpath($path); - Console::success('Saved mocks spec file: ' . realpath($path)); - - continue; - } - - $path = $specsDir . '/' . $format . '-' . $version . '-' . $platform . '.json'; - - if (!file_put_contents($path, json_encode($specs->parse(), JSON_PRETTY_PRINT))) { - throw new Exception('Failed to save spec file: ' . $path); + if (\file_put_contents($path, $encodedSpecs) === false) { + throw new Exception('Failed to save ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . $path); } $generatedFiles[] = realpath($path); - Console::success('Saved spec file: ' . realpath($path)); + Console::success('Saved ' . ($mocks ? 'mocks ' : '') . 'spec file: ' . realpath($path)); + + unset($encodedSpecs, $specs, $formatInstance); } + + unset($arguments, $models, $routes, $services); } if ($git === 'yes') { diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 1d61180963..f49674896e 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -30,6 +30,7 @@ class Upgrade extends Install ->param('interactive', 'Y', new Text(1), 'Run an interactive session', true) ->param('no-start', false, new Boolean(true), 'Run an interactive session', true) ->param('database', 'mongodb', new Text(length: 0), 'Database to use (mongodb|mariadb|postgresql)', true) + ->param('migrate', false, new Boolean(true), 'Run database migration after upgrade', true) ->callback($this->action(...)); } @@ -40,9 +41,11 @@ class Upgrade extends Install string $image, string $interactive, bool $noStart, - string $database + string $database, + bool $migrate = false, ): void { $this->isUpgrade = true; + $this->migrate = $migrate; $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, true); diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index 3065b2377f..716969e67a 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -54,6 +54,7 @@ class Deletes extends Action ->inject('project') ->inject('dbForPlatform') ->inject('getProjectDB') + ->inject('getDatabasesDB') ->inject('getLogsDB') ->inject('deviceForFiles') ->inject('deviceForFunctions') @@ -80,6 +81,7 @@ class Deletes extends Action Document $project, Database $dbForPlatform, callable $getProjectDB, + callable $getDatabasesDB, callable $getLogsDB, Device $deviceForFiles, Device $deviceForFunctions, @@ -115,7 +117,7 @@ class Deletes extends Action case DELETE_TYPE_DOCUMENT: switch ($document->getCollection()) { case DELETE_TYPE_PROJECTS: - $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document); + $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $document); break; case DELETE_TYPE_SITES: $this->deleteSite($dbForPlatform, $getProjectDB, $deviceForSites, $deviceForBuilds, $deviceForFiles, $document, $certificates, $project); @@ -150,7 +152,7 @@ class Deletes extends Action } break; case DELETE_TYPE_TEAM_PROJECTS: - $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $certificates, $document); + $this->deleteProjectsByTeam($dbForPlatform, $getProjectDB, $getDatabasesDB, $certificates, $document); break; case DELETE_TYPE_EXECUTIONS: $this->deleteExecutionLogs($project, $getProjectDB, $executionRetention); @@ -220,6 +222,50 @@ class Deletes extends Action } } + private function cleanDatabase( + Document $databaseDoc, + callable $executionActionPerDatabase, + bool $projectTables, + array $projectCollectionIds + ): void { + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => $this->cleanDatabaseCollections( + $dbForDatabases, + $projectTables, + $projectCollectionIds + ) + ); + } + + private function cleanDatabaseCollections( + Database $dbForDatabases, + bool $projectTables, + array $projectCollectionIds + ): void { + $dbForDatabases->foreach( + Database::METADATA, + function (Document $collection) use ($dbForDatabases, $projectTables, $projectCollectionIds) { + $collectionId = $collection->getId(); + + try { + if ($projectTables || !\in_array($collectionId, $projectCollectionIds, true)) { + $dbForDatabases->deleteCollection($collectionId); + return; + } + + $this->deleteByGroup( + $collectionId, + [Query::orderAsc()], + database: $dbForDatabases + ); + } catch (Throwable $e) { + Console::error('Error deleting ' . $collectionId . ' ' . $e->getMessage()); + } + } + ); + } + /** * @param Database $dbForPlatform * @param callable $getProjectDB @@ -547,7 +593,7 @@ class Deletes extends Action * @throws Structure * @throws Exception */ - protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, CertificatesAdapter $certificates, Document $document): void + protected function deleteProjectsByTeam(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, CertificatesAdapter $certificates, Document $document): void { $projects = $dbForPlatform->find('projects', [ @@ -562,7 +608,7 @@ class Deletes extends Action $deviceForBuilds = getDevice(APP_STORAGE_BUILDS . '/app-' . $project->getId()); $deviceForCache = getDevice(APP_STORAGE_CACHE . '/app-' . $project->getId()); - $this->deleteProject($dbForPlatform, $getProjectDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project); + $this->deleteProject($dbForPlatform, $getProjectDB, $getDatabasesDB, $deviceForFiles, $deviceForSites, $deviceForFunctions, $deviceForBuilds, $deviceForCache, $certificates, $project); $dbForPlatform->deleteDocument('projects', $project->getId()); } } @@ -580,7 +626,7 @@ class Deletes extends Action * @throws Authorization * @throws DatabaseException */ - protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void + protected function deleteProject(Database $dbForPlatform, callable $getProjectDB, callable $getDatabasesDB, Device $deviceForFiles, Device $deviceForSites, Device $deviceForFunctions, Device $deviceForBuilds, Device $deviceForCache, CertificatesAdapter $certificates, Document $document): void { $projectInternalId = $document->getSequence(); $projectId = $document->getId(); @@ -617,23 +663,44 @@ class Deletes extends Action $sharedTablesV1 = \in_array($dsn->getHost(), $sharedTablesV1); $sharedTablesV2 = !$projectTables && !$sharedTablesV1; - $dbForProject->foreach(Database::METADATA, function (Document $collection) use ($dbForProject, $projectTables, $projectCollectionIds) { - try { - if ($projectTables || !\in_array($collection->getId(), $projectCollectionIds)) { - $dbForProject->deleteCollection($collection->getId()); - } else { - $this->deleteByGroup( - $collection->getId(), - [ - Query::orderAsc() - ], - database: $dbForProject - ); - } - } catch (Throwable $e) { - Console::error('Error deleting ' . $collection->getId() . ' ' . $e->getMessage()); + $allDatabases = [ + new Document([ + 'database' => $document->getAttribute('database') + ]), + ...$dbForProject->find('databases', [ + Query::equal('type', [DATABASE_TYPE_DOCUMENTSDB, DATABASE_TYPE_VECTORSDB]), + Query::limit(5000), + ]), + ]; + $databasesToClean = []; + + foreach ($allDatabases as $db) { + $key = $db->getAttribute('database'); + + if ($key) { + $databasesToClean[$key] ??= $db; } - }); + } + + $databasesToClean = array_values($databasesToClean); + + $executionActionPerDatabase = function (Document $databaseDoc, $callback) use ($getDatabasesDB, $document) { + /** + * @var Database $dbForDatabases + */ + $dbForDatabases = $getDatabasesDB($databaseDoc, $document); + $callback($dbForDatabases); + }; + + batch(array_map( + fn ($databaseDoc) => fn () => $this->cleanDatabase( + $databaseDoc, + $executionActionPerDatabase, + $projectTables, + $projectCollectionIds + ), + $databasesToClean + )); // Delete Platforms $this->deleteByGroup('platforms', [ @@ -688,7 +755,15 @@ class Deletes extends Action // Delete metadata table if ($projectTables) { - $dbForProject->deleteCollection(Database::METADATA); + batch(array_map( + fn ($databaseDoc) => fn () => + $executionActionPerDatabase( + $databaseDoc, + fn (Database $dbForDatabases) => + $dbForDatabases->deleteCollection(Database::METADATA) + ), + $databasesToClean + )); } elseif ($sharedTablesV1) { $this->deleteByGroup( Database::METADATA, diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index 25d5bfa027..1080ff066f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -28,6 +28,7 @@ use Utopia\Locale\Locale; use Utopia\Migration\Destination; use Utopia\Migration\Destinations\Appwrite as DestinationAppwrite; use Utopia\Migration\Destinations\CSV as DestinationCSV; +use Utopia\Migration\Destinations\JSON as DestinationJSON; use Utopia\Migration\Exception as MigrationException; use Utopia\Migration\Resource; use Utopia\Migration\Resources\Database\Database as ResourceDatabase; @@ -37,6 +38,7 @@ use Utopia\Migration\Source; use Utopia\Migration\Sources\Appwrite as SourceAppwrite; use Utopia\Migration\Sources\CSV; use Utopia\Migration\Sources\Firebase; +use Utopia\Migration\Sources\JSON; use Utopia\Migration\Sources\NHost; use Utopia\Migration\Sources\Supabase; use Utopia\Migration\Transfer; @@ -52,6 +54,18 @@ class Migrations extends Action protected ?Device $deviceForMigrations; protected ?Device $deviceForFiles; protected ?Document $project; + + protected Document $sourceProject; + + /** + * @var callable + */ + protected mixed $getDatabasesDB; + + /** + * @var callable(Document $databaseDSN): Database + */ + protected mixed $getProjectDB; protected array $plan = []; /** @@ -81,6 +95,8 @@ class Migrations extends Action ->inject('project') ->inject('dbForProject') ->inject('dbForPlatform') + ->inject('getDatabasesDB') + ->inject('getProjectDB') ->inject('logError') ->inject('queueForRealtime') ->inject('deviceForMigrations') @@ -101,6 +117,8 @@ class Migrations extends Action Document $project, Database $dbForProject, Database $dbForPlatform, + callable $getDatabasesDB, + callable $getProjectDB, callable $logError, Realtime $queueForRealtime, Device $deviceForMigrations, @@ -112,6 +130,9 @@ class Migrations extends Action Authorization $authorization, ): void { $payload = $message->getPayload() ?? []; + $this->getDatabasesDB = $getDatabasesDB; + $this->getProjectDB = $getProjectDB; + $this->deviceForMigrations = $deviceForMigrations; $this->deviceForFiles = $deviceForFiles; $this->plan = $plan; @@ -180,14 +201,17 @@ class Migrations extends Action $resourceId = $migration->getAttribute('resourceId'); $credentials = $migration->getAttribute('credentials'); $migrationOptions = $migration->getAttribute('options'); - $dataSource = SourceAppwrite::SOURCE_API; - $database = null; + /** @var Database|null $projectDB */ + $projectDB = null; + if ($credentials['projectId']) { + $this->sourceProject = $this->dbForPlatform->getDocument('projects', $credentials['projectId']); + $projectDB = call_user_func($this->getProjectDB, $this->sourceProject); + } + $getDatabasesDB = fn (Document $database): Database => + $this->getDatabasesDBForProject($database); $queries = []; - - if ($source === SourceAppwrite::getName() && $destination === DestinationCSV::getName()) { - $dataSource = SourceAppwrite::SOURCE_DATABASE; - $database = $this->dbForProject; - $queries = Query::parseQueries($migrationOptions['queries']); + if ($source === SourceAppwrite::getName() && in_array($destination, [DestinationCSV::getName(), DestinationJSON::getName()])) { + $queries = Query::parseQueries($migrationOptions['queries'] ?? []); } $migrationSource = match ($source) { @@ -216,15 +240,23 @@ class Migrations extends Action $credentials['projectId'], $credentials['endpoint'], $credentials['apiKey'], - $dataSource, - $database, - $queries, + $getDatabasesDB, + SourceAppwrite::SOURCE_DATABASE, + $projectDB, + $queries ), CSV::getName() => new CSV( $resourceId, $migrationOptions['path'], $this->deviceForMigrations, - $this->dbForProject + $this->dbForProject, + $getDatabasesDB + ), + JSON::getName() => new JSON( + $resourceId, + $migrationOptions['path'], + $this->deviceForMigrations, + $this->dbForProject, ), default => throw new \Exception('Invalid source type'), }; @@ -250,6 +282,7 @@ class Migrations extends Action $credentials['destinationEndpoint'], $credentials['destinationApiKey'], $this->dbForProject, + $this->getDatabasesDB, Config::getParam('collections', [])['databases']['collections'], ), DestinationCSV::getName() => new DestinationCSV( @@ -263,6 +296,13 @@ class Migrations extends Action $options['escape'], $options['header'], ), + DestinationJSON::getName() => new DestinationJSON( + $this->deviceForFiles, + $migration->getAttribute('resourceId'), + $options['bucketId'] ?? 'default', + $options['filename'], + $options['columns'] ?? [], + ), default => throw new \Exception('Invalid destination type'), }; } @@ -303,6 +343,10 @@ class Migrations extends Action 'disabledMetrics' => [ METRIC_DATABASES_OPERATIONS_READS, METRIC_DATABASES_OPERATIONS_WRITES, + METRIC_DATABASES_OPERATIONS_READS_DOCUMENTSDB, + METRIC_DATABASES_OPERATIONS_WRITES_DOCUMENTSDB, + METRIC_DATABASES_OPERATIONS_READS_VECTORSDB, + METRIC_DATABASES_OPERATIONS_WRITES_VECTORSDB, METRIC_NETWORK_REQUESTS, METRIC_NETWORK_INBOUND, METRIC_NETWORK_OUTBOUND, @@ -333,7 +377,9 @@ class Migrations extends Action 'targets.read', 'targets.write', 'webhooks.read', - 'webhooks.write' + 'webhooks.write', + 'project.read', + 'project.write' ] ]); @@ -518,11 +564,10 @@ class Migrations extends Action } $destination?->success(); $source?->success(); - - // TODO: Move to CSV hook - if ($migration->getAttribute('destination') === DestinationCSV::getName()) { - $this->handleCSVExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); - } + } + $destination_type = $migration->getAttribute('destination'); + if ($destination_type === DestinationCSV::getName() || $destination_type === DestinationJSON::getName()) { + $this->handleDataExportComplete($project, $migration, $queueForMails, $queueForRealtime, $platform, $authorization); } } finally { $source?->cleanup(); @@ -535,6 +580,14 @@ class Migrations extends Action } } + protected function getDatabasesDBForProject(Document $database) + { + if ($this->sourceProject) { + return ($this->getDatabasesDB)($database, $this->sourceProject); + } + return ($this->getDatabasesDB)($database); + } + /** * Handle actions to be performed when a CSV export migration is successfully completed * @@ -546,7 +599,7 @@ class Migrations extends Action * @param Authorization $authorization * @return void */ - protected function handleCSVExportComplete( + protected function handleDataExportComplete( Document $project, Document $migration, Mail $queueForMails, @@ -571,7 +624,8 @@ class Migrations extends Action throw new \Exception('Bucket not found'); } - $path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . '.csv'); + $extension = $migration->getAttribute('destination') === DestinationJSON::getName() ? '.json' : '.csv'; + $path = $this->deviceForFiles->getPath($bucketId . '/' . $this->sanitizeFilename($filename) . $extension); $size = $this->deviceForFiles->getFileSize($path); $mime = $this->deviceForFiles->getFileMimeType($path); $hash = $this->deviceForFiles->getFileHash($path); @@ -595,13 +649,14 @@ class Migrations extends Action $migration->setAttribute('errors', $errors); $migration = $this->updateMigrationDocument($migration, $project, $queueForRealtime); - $this->sendCSVEmail( + $this->sendExportEmail( success: false, project: $project, user: $user, options: $options, queueForMails: $queueForMails, platform: $platform, + exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV', sizeMB: $sizeMB ); @@ -657,13 +712,14 @@ class Migrations extends Action $migration->setAttribute('options', $options); $this->updateMigrationDocument($migration, $project, $queueForRealtime); - $this->sendCSVEmail( + $this->sendExportEmail( success: true, project: $project, user: $user, options: $options, queueForMails: $queueForMails, platform: $platform, + exportType: $migration->getAttribute('destination') === DestinationJSON::getName() ? 'JSON' : 'CSV', downloadUrl: $downloadUrl ); } @@ -682,13 +738,14 @@ class Migrations extends Action * @return void * @throws \Exception */ - protected function sendCSVEmail( + protected function sendExportEmail( bool $success, Document $project, Document $user, array $options, Mail $queueForMails, array $platform, + string $exportType = 'CSV', string $downloadUrl = '', float $sizeMB = 0.0, ): void { @@ -708,15 +765,15 @@ class Migrations extends Action ? 'success' : 'failure'; - // Get localized email content - $subject = $locale->getText("emails.csvExport.{$emailType}.subject"); - $preview = $locale->getText("emails.csvExport.{$emailType}.preview"); - $hello = $locale->getText("emails.csvExport.{$emailType}.hello"); - $body = $locale->getText("emails.csvExport.{$emailType}.body"); - $footer = $locale->getText("emails.csvExport.{$emailType}.footer"); - $thanks = $locale->getText("emails.csvExport.{$emailType}.thanks"); - $signature = $locale->getText("emails.csvExport.{$emailType}.signature"); - $buttonText = $success ? $locale->getText("emails.csvExport.{$emailType}.buttonText") : ''; + // Get localized email content — replace {{type}} with export format (CSV/JSON) + $subject = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.subject")); + $preview = \str_replace('{{type}}', $exportType, $locale->getText("emails.dataExport.{$emailType}.preview")); + $hello = $locale->getText("emails.dataExport.{$emailType}.hello"); + $body = $locale->getText("emails.dataExport.{$emailType}.body"); + $footer = $locale->getText("emails.dataExport.{$emailType}.footer"); + $thanks = $locale->getText("emails.dataExport.{$emailType}.thanks"); + $signature = $locale->getText("emails.dataExport.{$emailType}.signature"); + $buttonText = $success ? $locale->getText("emails.dataExport.{$emailType}.buttonText") : ''; // Build email body using appropriate template $templatePath = $success @@ -732,6 +789,7 @@ class Migrations extends Action ->setParam('{{direction}}', $locale->getText('settings.direction')) ->setParam('{{project}}', $project->getAttribute('name')) ->setParam('{{user}}', $user->getAttribute('name', $user->getAttribute('email'))) + ->setParam('{{type}}', $exportType) ->setParam('{{size}}', $success ? '' : (string)$sizeMB); if ($success) { @@ -752,6 +810,7 @@ class Migrations extends Action 'terms' => $platform['termsUrl'], 'privacy' => $platform['privacyUrl'], 'platform' => $platform['platformName'], + 'type' => $exportType, ]; $queueForMails diff --git a/src/Appwrite/Platform/Workers/StatsResources.php b/src/Appwrite/Platform/Workers/StatsResources.php index e464455470..0e7a9bb0a7 100644 --- a/src/Appwrite/Platform/Workers/StatsResources.php +++ b/src/Appwrite/Platform/Workers/StatsResources.php @@ -47,6 +47,7 @@ class StatsResources extends Action ->inject('project') ->inject('getProjectDB') ->inject('getLogsDB') + ->inject('getDatabasesDB') ->inject('dbForPlatform') ->inject('logError') ->callback($this->action(...)); @@ -56,11 +57,13 @@ class StatsResources extends Action * @param Message $message * @param Document $project * @param callable $getProjectDB + * @param callable $getLogsDB + * @param callable $getDatabasesDB * @return void * @throws \Utopia\Database\Exception * @throws Exception */ - public function action(Message $message, Document $project, callable $getProjectDB, callable $getLogsDB, Database $dbForPlatform, callable $logError): void + public function action(Message $message, Document $project, callable $getProjectDB, callable $getLogsDB, callable $getDatabasesDB, Database $dbForPlatform, callable $logError): void { $this->logError = $logError; @@ -76,10 +79,10 @@ class StatsResources extends Action // Reset documents for each job $this->documents = []; - $this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $project); + $this->countForProject($dbForPlatform, $getLogsDB, $getProjectDB, $getDatabasesDB, $project); } - protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, Document $project): void + protected function countForProject(Database $dbForPlatform, callable $getLogsDB, callable $getProjectDB, callable $getDatabasesDB, Document $project): void { /** @var \Utopia\Database\Database $dbForLogs */ $dbForLogs = call_user_func($getLogsDB, $project); @@ -107,7 +110,9 @@ class StatsResources extends Action ]); - $databases = $dbForProject->count('databases'); + $databases = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_LEGACY, DATABASE_TYPE_TABLESDB])]); + $documentsdb = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_DOCUMENTSDB])]); + $vectorsdb = $dbForProject->count('databases', [Query::equal('type', [DATABASE_TYPE_VECTORSDB])]); $buckets = $dbForProject->count('buckets'); $users = $dbForProject->count('users'); @@ -142,6 +147,8 @@ class StatsResources extends Action $metrics = [ METRIC_DATABASES => $databases, + METRIC_DATABASES_DOCUMENTSDB => $documentsdb, + METRIC_DATABASES_VECTORSDB => $vectorsdb, METRIC_BUCKETS => $buckets, METRIC_USERS => $users, METRIC_FUNCTIONS => $functions, @@ -179,7 +186,7 @@ class StatsResources extends Action } try { - $dbForProject->skipFilters(fn () => $this->countForDatabase($dbForProject, $region), ['subQueryAttributes', 'subQueryIndexes']); + $dbForProject->skipFilters(fn () => $this->countForDatabase($dbForProject, $getDatabasesDB, $region), ['subQueryAttributes', 'subQueryIndexes']); } catch (Throwable $th) { call_user_func_array($this->logError, [$th, "StatsResources", "count_for_database_{$project->getId()}"]); } @@ -255,51 +262,101 @@ class StatsResources extends Action $this->createStatsDocuments($region, METRIC_FILES_IMAGES_TRANSFORMED, $totalImageTransformations); } - protected function countForDatabase(Database $dbForProject, string $region) + protected function countForDatabase(Database $dbForProject, callable $getDatabasesDB, string $region) { $totalCollections = 0; $totalDocuments = 0; - $totalDatabaseStorage = 0; - $this->foreachDocument($dbForProject, 'databases', [], function ($database) use ($dbForProject, $region, &$totalCollections, &$totalDocuments, &$totalDatabaseStorage) { + // documentsdb + $totalCollectionsDocumentsdb = 0; + $totalDocumentsDocumentsdb = 0; + $totalDatabaseStorageDocumentsdb = 0; + + // vectorsdb + $totalCollectionsVectordb = 0; + $totalDocumentsVectordb = 0; + $totalDatabaseStorageVectordb = 0; + + + $this->foreachDocument($dbForProject, 'databases', [], function ($database) use ($dbForProject, $getDatabasesDB, $region, &$totalCollections, &$totalDocuments, &$totalDatabaseStorage, &$totalCollectionsDocumentsdb, &$totalDocumentsDocumentsdb, &$totalDatabaseStorageDocumentsdb, &$totalCollectionsVectordb, &$totalDocumentsVectordb, &$totalDatabaseStorageVectordb) { + $dbForDatabases = $getDatabasesDB($database); $collections = $dbForProject->count('database_' . $database->getSequence()); - $metric = str_replace('{databaseInternalId}', $database->getSequence(), METRIC_DATABASE_ID_COLLECTIONS); + $databaseType = $database->getAttribute('type'); + $collectionsMetric = METRIC_DATABASE_ID_COLLECTIONS; + if (!empty($databaseType) && $databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $collectionsMetric = $databaseType . '.' . $collectionsMetric; + } + $metric = str_replace('{databaseInternalId}', $database->getSequence(), $collectionsMetric); $this->createStatsDocuments($region, $metric, $collections); - [$documents, $storage] = $this->countForCollections($dbForProject, $database, $region); + [$documents, $storage] = $this->countForCollections($dbForProject, $dbForDatabases, $database, $region); - $totalDatabaseStorage += $storage; - $totalDocuments += $documents; - $totalCollections += $collections; + switch ($database->getAttribute('type')) { + case DATABASE_TYPE_DOCUMENTSDB: + $totalDatabaseStorageDocumentsdb += $storage; + $totalDocumentsDocumentsdb += $documents; + $totalCollectionsDocumentsdb += $collections; + break; + case DATABASE_TYPE_VECTORSDB: + $totalDatabaseStorageVectordb += $storage; + $totalDocumentsVectordb += $documents; + $totalCollectionsVectordb += $collections; + break; + default: + $totalDatabaseStorage += $storage; + $totalDocuments += $documents; + $totalCollections += $collections; + } }); $this->createStatsDocuments($region, METRIC_COLLECTIONS, $totalCollections); $this->createStatsDocuments($region, METRIC_DOCUMENTS, $totalDocuments); $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE, $totalDatabaseStorage); + + $this->createStatsDocuments($region, METRIC_COLLECTIONS_DOCUMENTSDB, $totalCollectionsDocumentsdb); + $this->createStatsDocuments($region, METRIC_DOCUMENTS_DOCUMENTSDB, $totalDocumentsDocumentsdb); + $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE_DOCUMENTSDB, $totalDatabaseStorageDocumentsdb); + + $this->createStatsDocuments($region, METRIC_COLLECTIONS_VECTORSDB, $totalCollectionsVectordb); + $this->createStatsDocuments($region, METRIC_DOCUMENTS_VECTORSDB, $totalDocumentsVectordb); + $this->createStatsDocuments($region, METRIC_DATABASES_STORAGE_VECTORSDB, $totalDatabaseStorageVectordb); } - protected function countForCollections(Database $dbForProject, Document $database, string $region): array + protected function countForCollections(Database $dbForProject, Database $dbForDatabases, Document $database, string $region): array { $databaseDocuments = 0; $databaseStorage = 0; - $this->foreachDocument($dbForProject, 'database_' . $database->getSequence(), [], function ($collection) use ($dbForProject, $database, $region, &$databaseStorage, &$databaseDocuments) { - $documents = $dbForProject->count('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); - $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS); + $databaseType = $database->getAttribute('type'); + $databaseIdCollectionIdDocumentsMetric = METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS; + $databaseIdCollectionIdStorageMetric = METRIC_DATABASE_ID_COLLECTION_ID_STORAGE; + $databaseIdDocumentsMetric = METRIC_DATABASE_ID_DOCUMENTS; + $databaseIdStorageMetric = METRIC_DATABASE_ID_STORAGE; + + if ($databaseType !== DATABASE_TYPE_LEGACY && $databaseType !== DATABASE_TYPE_TABLESDB) { + $databaseIdCollectionIdDocumentsMetric = $databaseType . '.' . $databaseIdCollectionIdDocumentsMetric; + $databaseIdCollectionIdStorageMetric = $databaseType . '.' . $databaseIdCollectionIdStorageMetric; + $databaseIdDocumentsMetric = $databaseType . '.' . $databaseIdDocumentsMetric; + $databaseIdStorageMetric = $databaseType . '.' . $databaseIdStorageMetric; + } + + $this->foreachDocument($dbForProject, 'database_' . $database->getSequence(), [], function ($collection) use ($dbForDatabases, $database, $region, &$databaseStorage, &$databaseDocuments, $databaseIdCollectionIdDocumentsMetric, $databaseIdCollectionIdStorageMetric) { + $documents = $dbForDatabases->count('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], $databaseIdCollectionIdDocumentsMetric); $this->createStatsDocuments($region, $metric, $documents); $databaseDocuments += $documents; - $collectionStorage = $dbForProject->getSizeOfCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); - $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], METRIC_DATABASE_ID_COLLECTION_ID_STORAGE); + $collectionStorage = $dbForDatabases->getSizeOfCollection('database_' . $database->getSequence() . '_collection_' . $collection->getSequence()); + $metric = str_replace(['{databaseInternalId}', '{collectionInternalId}'], [$database->getSequence(), $collection->getSequence()], $databaseIdCollectionIdStorageMetric); $this->createStatsDocuments($region, $metric, $collectionStorage); $databaseStorage += $collectionStorage; }); - $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], METRIC_DATABASE_ID_DOCUMENTS); + $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], $databaseIdDocumentsMetric); $this->createStatsDocuments($region, $metric, $databaseDocuments); - $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], METRIC_DATABASE_ID_STORAGE); + $metric = str_replace(['{databaseInternalId}'], [$database->getSequence()], $databaseIdStorageMetric); $this->createStatsDocuments($region, $metric, $databaseStorage); return [$databaseDocuments, $databaseStorage]; diff --git a/src/Appwrite/Platform/Workers/StatsUsage.php b/src/Appwrite/Platform/Workers/StatsUsage.php index 76be33d06b..1e0a2eabba 100644 --- a/src/Appwrite/Platform/Workers/StatsUsage.php +++ b/src/Appwrite/Platform/Workers/StatsUsage.php @@ -47,6 +47,8 @@ class StatsUsage extends Action */ protected array $skipBaseMetrics = [ METRIC_DATABASES => true, + METRIC_DATABASES_DOCUMENTSDB => true, + METRIC_DATABASES_VECTORSDB => true, METRIC_BUCKETS => true, METRIC_USERS => true, METRIC_FUNCTIONS => true, @@ -66,7 +68,13 @@ class StatsUsage extends Action METRIC_BUILDS => true, METRIC_COLLECTIONS => true, METRIC_DOCUMENTS => true, + METRIC_COLLECTIONS_DOCUMENTSDB => true, + METRIC_DOCUMENTS_DOCUMENTSDB => true, + METRIC_COLLECTIONS_VECTORSDB => true, + METRIC_DOCUMENTS_VECTORSDB => true, METRIC_DATABASES_STORAGE => true, + METRIC_DATABASES_STORAGE_DOCUMENTSDB => true, + METRIC_DATABASES_STORAGE_VECTORSDB => true, ]; /** @@ -85,6 +93,12 @@ class StatsUsage extends Action '.databases.storage' ]; + public const DATABASE_PREFIXES = [ + DATABASE_TYPE_LEGACY, + DATABASE_TYPE_TABLESDB, + DATABASE_TYPE_DOCUMENTSDB, + ]; + /** * @var callable(): Database */ @@ -146,6 +160,11 @@ class StatsUsage extends Action $aggregationInterval = (int) System::getEnv('_APP_USAGE_AGGREGATION_INTERVAL', '20'); $project = new Document($payload['project'] ?? []); $projectId = $project->getSequence(); + + // Get database type from context + $databaseContext = $payload['context']['database'] ?? null; + $databaseType = $databaseContext ? (new Document($databaseContext))->getAttribute('type', '') : ''; + foreach ($payload['reduce'] ?? [] as $document) { if (empty($document)) { continue; @@ -155,7 +174,8 @@ class StatsUsage extends Action project: $project, document: new Document($document), metrics: $payload['metrics'], - getProjectDB: $getProjectDB + getProjectDB: $getProjectDB, + databaseType: $databaseType ); } @@ -193,9 +213,10 @@ class StatsUsage extends Action * @param Document $document * @param array $metrics * @param callable(): Database $getProjectDB + * @param string $databaseType Database type from context * @return void */ - protected function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB): void + protected function reduce(Document $project, Document $document, array &$metrics, callable $getProjectDB, string $databaseType = ''): void { $dbForProject = $getProjectDB($project); @@ -211,38 +232,48 @@ class StatsUsage extends Action } break; case $document->getCollection() === 'databases': // databases - $collections = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), METRIC_DATABASE_ID_COLLECTIONS))); - $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), METRIC_DATABASE_ID_DOCUMENTS))); + $databaseCollectionsMetric = implode('.', array_filter([$databaseType,METRIC_COLLECTIONS])); + $databaseDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DOCUMENTS])); + + $databaseIdCollectionsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_COLLECTIONS])); + $databaseIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_DOCUMENTS])); + + $collections = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), $databaseIdCollectionsMetric))); + $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace('{databaseInternalId}', $document->getSequence(), $databaseIdDocumentsMetric))); if (!empty($collections['value'])) { $metrics[] = [ - 'key' => METRIC_COLLECTIONS, + 'key' => $databaseCollectionsMetric, 'value' => ($collections['value'] * -1), ]; } if (!empty($documents['value'])) { $metrics[] = [ - 'key' => METRIC_DOCUMENTS, + 'key' => $databaseDocumentsMetric, 'value' => ($documents['value'] * -1), ]; } break; case str_starts_with($document->getCollection(), 'database_') && !str_contains($document->getCollection(), 'collection'): //collections + $databaseDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DOCUMENTS])); + $databaseIdCollectionIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS])); + $databaseIdDocumentsMetric = implode('.', array_filter([$databaseType,METRIC_DATABASE_ID_DOCUMENTS])); + $parts = explode('_', $document->getCollection()); $databaseInternalId = $parts[1] ?? 0; $documents = $dbForProject->getDocument('stats', md5(self::INFINITY_PERIOD . str_replace( ['{databaseInternalId}', '{collectionInternalId}'], [$databaseInternalId, $document->getSequence()], - METRIC_DATABASE_ID_COLLECTION_ID_DOCUMENTS + $databaseIdCollectionIdDocumentsMetric ))); if (!empty($documents['value'])) { $metrics[] = [ - 'key' => METRIC_DOCUMENTS, + 'key' => $databaseDocumentsMetric, 'value' => ($documents['value'] * -1), ]; $metrics[] = [ - 'key' => str_replace('{databaseInternalId}', $databaseInternalId, METRIC_DATABASE_ID_DOCUMENTS), + 'key' => str_replace('{databaseInternalId}', $databaseInternalId, $databaseIdDocumentsMetric), 'value' => ($documents['value'] * -1), ]; } @@ -473,8 +504,11 @@ class StatsUsage extends Action if (array_key_exists($stat->getAttribute('metric'), $this->skipBaseMetrics)) { return; } + foreach ($this->skipParentIdMetrics as $skipMetric) { - if (str_ends_with($stat->getAttribute('metric'), $skipMetric)) { + $metricParts = explode('.', $stat->getAttribute('metric')); + $metric = implode('.', in_array($metricParts[0], self::DATABASE_PREFIXES) ? array_slice($metricParts, 1) : $metricParts); + if (str_ends_with($metric, $skipMetric)) { return; } } diff --git a/src/Appwrite/SDK/Specification/Format.php b/src/Appwrite/SDK/Specification/Format.php index bd2f063073..04ecafa8fc 100644 --- a/src/Appwrite/SDK/Specification/Format.php +++ b/src/Appwrite/SDK/Specification/Format.php @@ -309,7 +309,7 @@ abstract class Format case 'createIndex': switch ($param) { case 'type': - return 'IndexType'; + return 'DatabasesIndexType'; case 'orders': return 'OrderBy'; } @@ -342,7 +342,45 @@ abstract class Format case 'createIndex': switch ($param) { case 'type': - return 'IndexType'; + return 'TablesDBIndexType'; + case 'orders': + return 'OrderBy'; + } + } + break; + case 'documentsDB': + switch ($method) { + case 'getUsage': + case 'listUsage': + case 'getCollectionUsage': + switch ($param) { + case 'range': + return 'UsageRange'; + } + break; + case 'createIndex': + switch ($param) { + case 'type': + return 'DocumentsDBIndexType'; + case 'orders': + return 'OrderBy'; + } + } + break; + case 'vectorsDB': + switch ($method) { + case 'getUsage': + case 'listUsage': + case 'getCollectionUsage': + switch ($param) { + case 'range': + return 'UsageRange'; + } + break; + case 'createIndex': + switch ($param) { + case 'type': + return 'VectorsDBIndexType'; case 'orders': return 'OrderBy'; } @@ -630,6 +668,8 @@ abstract class Format } break; case 'databases': + case 'documentsDB': + case 'vectorsDB': switch ($method) { case 'getUsage': case 'listUsage': diff --git a/src/Appwrite/Template/Template.php b/src/Appwrite/Template/Template.php index c8744c87bb..695e925e52 100644 --- a/src/Appwrite/Template/Template.php +++ b/src/Appwrite/Template/Template.php @@ -149,7 +149,7 @@ class Template extends View /** * From Camel Case * - * @var string $input + * @param string $input * * @return string */ @@ -167,7 +167,7 @@ class Template extends View /** * From Camel Case to Dash Case * - * @var string $input + * @param string $input * * @return string */ diff --git a/src/Appwrite/Utopia/Database/Documents/User.php b/src/Appwrite/Utopia/Database/Documents/User.php index bef73b31d9..50e66dac38 100644 --- a/src/Appwrite/Utopia/Database/Documents/User.php +++ b/src/Appwrite/Utopia/Database/Documents/User.php @@ -102,7 +102,7 @@ class User extends Document * * @return bool */ - public static function isPrivileged(array $roles): bool + public function isPrivileged(array $roles): bool { if ( in_array(self::ROLE_OWNER, $roles) || @@ -122,7 +122,7 @@ class User extends Document * * @return bool */ - public static function isApp(array $roles): bool + public function isApp(array $roles): bool { if (in_array(self::ROLE_APPS, $roles)) { return true; @@ -175,7 +175,5 @@ class User extends Document } return false; - - return false; } } diff --git a/src/Appwrite/Utopia/Database/Validator/Attributes.php b/src/Appwrite/Utopia/Database/Validator/Attributes.php index aac5ec2f37..f8bdd01103 100644 --- a/src/Appwrite/Utopia/Database/Validator/Attributes.php +++ b/src/Appwrite/Utopia/Database/Validator/Attributes.php @@ -45,10 +45,12 @@ class Attributes extends Validator /** * @param int $maxAttributes Maximum number of attributes allowed * @param bool $supportForSpatialAttributes Whether DB supports spatial attributes + * @param bool $supportForAttributes Whether DB supports attributes or not */ public function __construct( int $maxAttributes = APP_LIMIT_ARRAY_PARAMS_SIZE, protected bool $supportForSpatialAttributes = true, + protected bool $supportForAttributes = true ) { $this->maxAttributes = $maxAttributes; } @@ -78,6 +80,11 @@ class Attributes extends Validator return false; } + if (\count($value) && !$this->supportForAttributes) { + $this->message = 'Attributes are not supported by the current database'; + return false; + } + if (\count($value) > $this->maxAttributes) { $this->message = 'Maximum of ' . $this->maxAttributes . ' attributes allowed'; return false; diff --git a/src/Appwrite/Utopia/Database/Validator/Operation.php b/src/Appwrite/Utopia/Database/Validator/Operation.php index e6884ac677..6d50611708 100644 --- a/src/Appwrite/Utopia/Database/Validator/Operation.php +++ b/src/Appwrite/Utopia/Database/Validator/Operation.php @@ -59,6 +59,7 @@ class Operation extends Validator { switch ($this->type) { case 'legacy': + case 'documentsdb': $this->collectionIdName = 'collectionId'; $this->documentIdName = 'documentId'; break; diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php index 02f2a57c5b..9d9bbde00b 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Base.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Base.php @@ -87,8 +87,6 @@ class Base extends Queries $allAttributes[] = $attribute; } - - $validators = [ new Limit(), new Offset(), diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php b/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php index 5d7a5e5cee..222f571281 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Variables.php @@ -7,7 +7,8 @@ class Variables extends Base public const ALLOWED_ATTRIBUTES = [ 'key', 'resourceType', - 'resourceId' + 'resourceId', + 'secret', ]; /** diff --git a/src/Appwrite/Utopia/Request.php b/src/Appwrite/Utopia/Request.php index a5b3d038bb..9428ff9d88 100644 --- a/src/Appwrite/Utopia/Request.php +++ b/src/Appwrite/Utopia/Request.php @@ -215,7 +215,7 @@ class Request extends UtopiaRequest $forwardedUserAgent = $this->getHeader('x-forwarded-user-agent'); if (!empty($forwardedUserAgent)) { $roles = $this->authorization->getRoles(); - $isAppUser = User::isApp($roles); + $isAppUser = $this->user?->isApp($roles) ?? false; if ($isAppUser) { return $forwardedUserAgent; @@ -239,9 +239,15 @@ class Request extends UtopiaRequest } private ?Authorization $authorization = null; + private ?User $user = null; public function setAuthorization(Authorization $authorization): void { $this->authorization = $authorization; } + + public function setUser(User $user): void + { + $this->user = $user; + } } diff --git a/src/Appwrite/Utopia/Request/Filters/V17.php b/src/Appwrite/Utopia/Request/Filters/V17.php index 2cdf3973b2..0e4f9eceb6 100644 --- a/src/Appwrite/Utopia/Request/Filters/V17.php +++ b/src/Appwrite/Utopia/Request/Filters/V17.php @@ -120,11 +120,11 @@ class V17 extends Filter $isArrayStack = !$isStringStack && $stackCount > 0; if ($char === static::CHAR_BACKSLASH) { - if (!(static::isSpecialChar($filter[$i + 1]))) { - static::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); + if (!(self::isSpecialChar($filter[$i + 1]))) { + self::appendSymbol($isStringStack, $filter[$i], $i, $filter, $currentParam); } - static::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); + self::appendSymbol($isStringStack, $filter[$i + 1], $i, $filter, $currentParam); $i++; continue; @@ -147,7 +147,7 @@ class V17 extends Filter } // Either way, add symbol to builder - static::appendSymbol( + self::appendSymbol( $isStringStack, $char, $i, @@ -199,7 +199,7 @@ class V17 extends Filter } // Value, not relevant to syntax - static::appendSymbol( + self::appendSymbol( $isStringStack, $char, $i, diff --git a/src/Appwrite/Utopia/Request/Filters/V21.php b/src/Appwrite/Utopia/Request/Filters/V21.php index 74e1fcfaff..1fd6ba9dc4 100644 --- a/src/Appwrite/Utopia/Request/Filters/V21.php +++ b/src/Appwrite/Utopia/Request/Filters/V21.php @@ -2,6 +2,7 @@ namespace Appwrite\Utopia\Request\Filters; +use Appwrite\Query; use Appwrite\Utopia\Request\Filter; class V21 extends Filter @@ -13,6 +14,12 @@ class V21 extends Filter case 'webhooks.create': $content = $this->fillWebhookid($content); break; + case 'project.createVariable': + $content = $this->fillVariableId($content); + break; + case 'project.listVariables': + $content = $this->preserveVariablesQueries($content); + break; case 'functions.createTemplateDeployment': case 'sites.createTemplateDeployment': $content = $this->convertVersionToTypeAndReference($content); @@ -57,4 +64,19 @@ class V21 extends Filter $content['webhookId'] = $content['webhookId'] ?? 'unique()'; return $content; } + + protected function fillVariableId(array $content): array + { + $content['variableId'] = $content['variableId'] ?? 'unique()'; + return $content; + } + + protected function preserveVariablesQueries(array $content): array + { + $content['queries'] = $content['queries'] ?? [ + Query::limit(APP_LIMIT_SUBQUERY) + ]; + + return $content; + } } diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 682c645047..e01dc58bf6 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -32,6 +32,10 @@ class Response extends SwooleResponse public const MODEL_BASE_LIST = 'baseList'; public const MODEL_USAGE_DATABASES = 'usageDatabases'; public const MODEL_USAGE_DATABASE = 'usageDatabase'; + public const MODEL_USAGE_DOCUMENTSDBS = 'usageDocumentsDBs'; + public const MODEL_USAGE_DOCUMENTSDB = 'usageDocumentsDB'; + public const MODEL_USAGE_VECTORSDBS = 'usageVectorsDBs'; + public const MODEL_USAGE_VECTORSDB = 'usageVectorsDB'; public const MODEL_USAGE_TABLE = 'usageTable'; public const MODEL_USAGE_COLLECTION = 'usageCollection'; public const MODEL_USAGE_USERS = 'usageUsers'; @@ -48,6 +52,10 @@ class Response extends SwooleResponse public const MODEL_DATABASE_LIST = 'databaseList'; public const MODEL_COLLECTION = 'collection'; public const MODEL_COLLECTION_LIST = 'collectionList'; + public const MODEL_VECTORSDB_COLLECTION = 'vectorsdbCollection'; + public const MODEL_VECTORSDB_COLLECTION_LIST = 'vectorsdbCollectionList'; + public const MODEL_EMBEDDING = 'embedding'; + public const MODEL_EMBEDDING_LIST = 'embeddingList'; public const MODEL_TABLE = 'table'; public const MODEL_TABLE_LIST = 'tableList'; public const MODEL_INDEX = 'index'; @@ -79,6 +87,8 @@ class Response extends SwooleResponse public const MODEL_ATTRIBUTE_TEXT = 'attributeText'; public const MODEL_ATTRIBUTE_MEDIUMTEXT = 'attributeMediumtext'; public const MODEL_ATTRIBUTE_LONGTEXT = 'attributeLongtext'; + public const MODEL_ATTRIBUTE_OBJECT = 'attributeObject'; + public const MODEL_ATTRIBUTE_VECTOR = 'attributeVector'; // Database Columns public const MODEL_COLUMN = 'column'; @@ -495,8 +505,9 @@ class Response extends SwooleResponse if ($rule['sensitive']) { $roles = $this->authorization->getRoles(); - $isPrivilegedUser = DBUser::isPrivileged($roles); - $isAppUser = DBUser::isApp($roles); + $user = $this->user ?? new DBUser(); + $isPrivilegedUser = $user->isPrivileged($roles); + $isAppUser = $user->isApp($roles); if ((!$isPrivilegedUser && !$isAppUser) && !self::$showSensitive) { $data->setAttribute($key, ''); @@ -618,9 +629,9 @@ class Response extends SwooleResponse } /** - * Return the currently set filter + * Return the currently set filters * - * @return Filter + * @return array */ public function getFilters(): array { @@ -650,7 +661,7 @@ class Response extends SwooleResponse /** * Static wrapper to show sensitive data in response * - * @param callable The callback to show sensitive information for + * @param callable(): array $callback The callback to show sensitive information for * @return array */ public static function showSensitive(callable $callback): array @@ -664,9 +675,15 @@ class Response extends SwooleResponse } private ?Authorization $authorization = null; + private ?DBUser $user = null; public function setAuthorization(Authorization $authorization): void { $this->authorization = $authorization; } + + public function setUser(DBUser $user): void + { + $this->user = $user; + } } diff --git a/src/Appwrite/Utopia/Response/Filters/V21.php b/src/Appwrite/Utopia/Response/Filters/V21.php index b65e26a8b0..3fc16d6c8a 100644 --- a/src/Appwrite/Utopia/Response/Filters/V21.php +++ b/src/Appwrite/Utopia/Response/Filters/V21.php @@ -11,34 +11,48 @@ class V21 extends Filter public function parse(array $content, string $model): array { return match ($model) { + Response::MODEL_USER => $this->parseUser($content), + Response::MODEL_USER_LIST => $this->handleList( + $content, + 'users', + fn ($item) => $this->parseUser($item), + ), + Response::MODEL_ACCOUNT => $this->parseUser($content), Response::MODEL_SITE => $this->parseSite($content), Response::MODEL_SITE_LIST => $this->handleList( $content, - "sites", + 'sites', fn ($item) => $this->parseSite($item), ), Response::MODEL_FUNCTION => $this->parseFunction($content), Response::MODEL_FUNCTION_LIST => $this->handleList( $content, - "functions", + 'functions', fn ($item) => $this->parseFunction($item), ), Response::MODEL_DOCUMENT => $this->parseDocument($content), Response::MODEL_DOCUMENT_LIST => $this->handleList( $content, - "documents", + 'documents', fn ($item) => $this->parseDocument($item), ), Response::MODEL_ROW => $this->parseRow($content), Response::MODEL_ROW_LIST => $this->handleList( $content, - "rows", + 'rows', fn ($item) => $this->parseRow($item), ), default => $content, }; } + protected function parseUser(array $content): array + { + unset($content['impersonator']); + unset($content['impersonatorUserId']); + return $content; + } + protected function parseSite(array $content): array { $content = $this->parseSpecs($content); diff --git a/src/Appwrite/Utopia/Response/Model/AttributeObject.php b/src/Appwrite/Utopia/Response/Model/AttributeObject.php new file mode 100644 index 0000000000..542f7f744c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/AttributeObject.php @@ -0,0 +1,27 @@ + 'object', + ]; + + public function getName(): string + { + return 'AttributeObject'; + } + + public function getType(): string + { + return Response::MODEL_ATTRIBUTE_OBJECT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/AttributeVector.php b/src/Appwrite/Utopia/Response/Model/AttributeVector.php new file mode 100644 index 0000000000..4b58b979ee --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/AttributeVector.php @@ -0,0 +1,35 @@ +addRule('size', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Vector dimensions.', + 'default' => 0, + 'example' => 1536, + ]); + } + + public array $conditions = [ + 'type' => 'vector', + ]; + + public function getName(): string + { + return 'AttributeVector'; + } + + public function getType(): string + { + return Response::MODEL_ATTRIBUTE_VECTOR; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Database.php b/src/Appwrite/Utopia/Response/Model/Database.php index 59f32b3162..df9ad2e1f5 100644 --- a/src/Appwrite/Utopia/Response/Model/Database.php +++ b/src/Appwrite/Utopia/Response/Model/Database.php @@ -45,9 +45,8 @@ class Database extends Model 'description' => 'Database type.', 'default' => 'legacy', 'example' => 'legacy', - 'enum' => ['legacy', 'tablesdb'], - ]) - ; + 'enum' => ['legacy', 'tablesdb', 'documentsdb', 'vectorsdb'], + ]); } /** diff --git a/src/Appwrite/Utopia/Response/Model/Embedding.php b/src/Appwrite/Utopia/Response/Model/Embedding.php new file mode 100644 index 0000000000..9fce913723 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/Embedding.php @@ -0,0 +1,47 @@ +addRule('model', [ + 'type' => self::TYPE_STRING, + 'description' => 'Embedding model used to generate embeddings.', + 'example' => 'embeddinggemma' + ]) + ->addRule('dimension', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Number of dimensions for each embedding vector.', + 'example' => 768 + ]) + ->addRule('embedding', [ + 'type' => self::TYPE_FLOAT, + 'array' => true, + 'default' => [], + 'description' => 'Embedding vector values. If an error occurs, this will be an empty array.', + 'example' => [0.01, 0.02, 0.03] + ]) + ->addRule('error', [ + 'type' => self::TYPE_STRING, + 'array' => false, + 'default' => '', + 'description' => 'Error message if embedding generation fails. Empty string if no error.', + 'example' => 'Error message' + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/MFAType.php b/src/Appwrite/Utopia/Response/Model/MFAType.php index 5f4a272796..807d3f5c8b 100644 --- a/src/Appwrite/Utopia/Response/Model/MFAType.php +++ b/src/Appwrite/Utopia/Response/Model/MFAType.php @@ -14,13 +14,13 @@ class MFAType extends Model 'type' => self::TYPE_STRING, 'description' => 'Secret token used for TOTP factor.', 'default' => '', - 'example' => true + 'example' => '[SHARED_SECRET]' ]) ->addRule('uri', [ 'type' => self::TYPE_STRING, 'description' => 'URI for authenticator apps.', 'default' => '', - 'example' => true + 'example' => 'otpauth://totp/appwrite:user@example.com?secret=[SHARED_SECRET]&issuer=appwrite' ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php new file mode 100644 index 0000000000..099a9887b8 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDB.php @@ -0,0 +1,96 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage used in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage used in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageDocumentsDB'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_DOCUMENTSDB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php new file mode 100644 index 0000000000..5ce229ce4a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageDocumentsDBs.php @@ -0,0 +1,109 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('databasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of DocumentsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of total databases storage in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of databases reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of databases writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of the aggregated number of databases storage in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageDocumentsDBs'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_DOCUMENTSDBS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageProject.php b/src/Appwrite/Utopia/Response/Model/UsageProject.php index ee644aa845..e00c4bc1dc 100644 --- a/src/Appwrite/Utopia/Response/Model/UsageProject.php +++ b/src/Appwrite/Utopia/Response/Model/UsageProject.php @@ -18,7 +18,13 @@ class UsageProject extends Model ]) ->addRule('documentsTotal', [ 'type' => self::TYPE_INTEGER, - 'description' => 'Total aggregated number of documents.', + 'description' => 'Total aggregated number of documents in legacy/tablesdb.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsdbDocumentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents in documentsdb.', 'default' => 0, 'example' => 0, ]) @@ -34,12 +40,24 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documentsdb.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('databasesStorageTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated sum of databases storage size (in bytes).', 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbDatabasesStorageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated sum of documentsdb databases storage size (in bytes).', + 'default' => 0, + 'example' => 0, + ]) ->addRule('usersTotal', [ 'type' => self::TYPE_INTEGER, 'description' => 'Total aggregated number of users.', @@ -100,6 +118,18 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + ->addRule('documentsdbDatabasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of documentsdb databases reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsdbDatabasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of documentsdb databases writes.', + 'default' => 0, + 'example' => 0, + ]) ->addRule('requests', [ 'type' => Response::MODEL_METRIC, 'description' => 'Aggregated number of requests per period.', @@ -203,6 +233,27 @@ class UsageProject extends Model 'example' => [], 'array' => true ]) + ->addRule('documentsdbDatabasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of documentsdb database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documentsdbDatabasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of documentsdb database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documentsdbDatabasesStorage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated sum of documentsdb databases storage size (in bytes) per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) ->addRule('imageTransformations', [ 'type' => Response::MODEL_METRIC, 'description' => 'An array of aggregated number of image transformations.', @@ -216,6 +267,133 @@ class UsageProject extends Model 'default' => 0, 'example' => 0, ]) + // VectorsDB aggregates + ->addRule('vectorsdbDatabasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbCollectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDocumentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesStorageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated VectorsDB storage (bytes).', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('vectorsdbDatabases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbCollections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDocuments', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesStorage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB storage per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB reads per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('vectorsdbDatabasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated VectorsDB writes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('embeddingsText', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of text embedding calls per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextTokens', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of tokens processed by text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextDuration', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated duration spent generating text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextErrors', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of errors while generating text embeddings per period.', + 'default' => [], + 'example' => [] + ]) + ->addRule('embeddingsTextTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of text embedding calls.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextTokensTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of tokens processed by text.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextDurationTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated duration spent generating text embeddings.', + 'default' => 0, + 'example' => 0 + ]) + ->addRule('embeddingsTextErrorsTotal', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Total aggregated number of errors while generating text embeddings.', + 'default' => 0, + 'example' => 0 + ]) ; } diff --git a/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php b/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php new file mode 100644 index 0000000000..c652a3d62e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageVectorsDB.php @@ -0,0 +1,96 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage used in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databaseWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage used in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databaseWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageVectorsDB'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_VECTORSDB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php b/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php new file mode 100644 index 0000000000..1f5fe7853d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/UsageVectorsDBs.php @@ -0,0 +1,109 @@ +addRule('range', [ + 'type' => self::TYPE_STRING, + 'description' => 'Time range of the usage stats.', + 'default' => '', + 'example' => '30d', + ]) + ->addRule('databasesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of VectorsDB databases.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('collectionsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of collections.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('documentsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated number of documents.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('storageTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total aggregated storage in bytes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesReadsTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database reads.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databasesWritesTotal', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of database writes.', + 'default' => 0, + 'example' => 0, + ]) + ->addRule('databases', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of databases per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('collections', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of collections per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('documents', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated number of documents per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('storage', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'Aggregated storage in bytes per period.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesReads', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database reads.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ->addRule('databasesWrites', [ + 'type' => Response::MODEL_METRIC, + 'description' => 'An array of aggregated number of database writes.', + 'default' => [], + 'example' => [], + 'array' => true + ]) + ; + } + + public function getName(): string + { + return 'UsageVectorsDBs'; + } + + public function getType(): string + { + return Response::MODEL_USAGE_VECTORSDBS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 476778e68b..01447ccfc2 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -157,9 +157,9 @@ class User extends Model } /** - * Get Collection + * Filter user document attributes for response output. * - * @return string + * @return Document */ public function filter(Document $document): Document { diff --git a/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php b/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php new file mode 100644 index 0000000000..5053628e74 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/VectorsDBCollection.php @@ -0,0 +1,41 @@ +addRule('dimension', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Embedding dimension.', + 'default' => 0, + 'example' => 1536, + ]) + ->addRule('attributes', [ + 'type' => [ + Response::MODEL_ATTRIBUTE_OBJECT, + Response::MODEL_ATTRIBUTE_VECTOR, + ], + 'description' => 'Collection attributes.', + 'default' => [], + 'example' => new \stdClass(), + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'VectorsDB Collection'; + } + + public function getType(): string + { + return Response::MODEL_VECTORSDB_COLLECTION; + } +} diff --git a/tests/e2e/General/UsageTest.php b/tests/e2e/General/UsageTest.php index 0e484d4dcf..eea53d9ea8 100644 --- a/tests/e2e/General/UsageTest.php +++ b/tests/e2e/General/UsageTest.php @@ -3,6 +3,7 @@ namespace Tests\E2E\General; use Appwrite\Platform\Modules\Compute\Specification; +use Appwrite\Tests\Retry; use CURLFile; use DateTime; use PHPUnit\Framework\Attributes\Depends; @@ -599,6 +600,8 @@ class UsageTest extends Scope $collectionsTotal = $data['collectionsTotal']; $documentsTotal = $data['documentsTotal']; + sleep(self::WAIT); + $this->assertEventually(function () use ($requestsTotal, $databasesTotal, $documentsTotal) { $response = $this->client->call( Client::METHOD_GET, @@ -923,6 +926,446 @@ class UsageTest extends Scope } #[Depends('testDatabaseStatsTablesAPI')] + public function testPrepareDocumentsDBStats(array $data): array + { + $documentsTotal = 0; + $collectionsTotal = 0; + $documentsDbTotal = 0; + $databasesTotal = $data['databasesTotal']; + $requestsTotal = $data['requestsTotal']; + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' documentsdb'; + + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'databaseId' => 'unique()', + 'name' => $name, + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsDbTotal += 1; + + $documentsDbId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsDbTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' collection'; + + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb/' . $documentsDbId . '/collections', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'collectionId' => 'unique()', + 'name' => $name, + 'documentSecurity' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $collectionsTotal += 1; + + $collectionId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $collectionsTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/documents', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'documentId' => 'unique()', + 'data' => [ + 'name' => uniqid() . ' document', + 'value' => $i + ] + ] + ); + + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsTotal += 1; + + $documentId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/documents/' . $documentId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsTotal -= 1; + $requestsTotal += 1; + } + } + + return array_merge($data, [ + 'documentsDbId' => $documentsDbId, + 'documentsDbCollectionId' => $collectionId, + 'requestsTotal' => $requestsTotal, + 'databasesTotal' => $databasesTotal, + 'documentsDbTotal' => $documentsDbTotal, + 'documentsDbCollectionsTotal' => $collectionsTotal, + 'documentsDbDocumentsTotal' => $documentsTotal, + ]); + } + + #[Depends('testPrepareDocumentsDBStats')] + #[Retry(count: 1)] + public function testDocumentsDBStats(array $data): array + { + $documentsDbId = $data['documentsDbId']; + $collectionId = $data['documentsDbCollectionId']; + $requestsTotal = $data['requestsTotal']; + $databasesTotal = $data['databasesTotal']; + $documentsDbTotal = $data['documentsDbTotal']; + $collectionsTotal = $data['documentsDbCollectionsTotal']; + $documentsTotal = $data['documentsDbDocumentsTotal']; + + sleep(self::WAIT); + + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertGreaterThanOrEqual(31, count($response['body'])); + $this->assertCount(1, $response['body']['requests']); + $this->assertCount(1, $response['body']['network']); + $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); + $this->validateDates($response['body']['requests']); + // documentsdbTotal should reflect only documents DB instances, not relational databases. + $this->assertEquals($documentsDbTotal, $response['body']['documentsdbTotal']); + $this->assertEquals($documentsTotal, $response['body']['documentsdbDocumentsTotal']); + + $response = $this->client->call( + Client::METHOD_GET, + '/databases/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); + $this->validateDates($response['body']['databases']); + + $this->assertEventually(function () use ($documentsDbId, $collectionsTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/documentsdb/' . $documentsDbId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($collectionsTotal, $response['body']['collections'][array_key_last($response['body']['collections'])]['value']); + $this->validateDates($response['body']['collections']); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + $this->assertEventually(function () use ($documentsDbId, $collectionId, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/documentsdb/' . $documentsDbId . '/collections/' . $collectionId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + return $data; + } + + #[Depends('testDocumentsDBStats')] + public function testPrepareVectorsDBStats(array $data): array + { + $documentsTotal = 0; + $collectionsTotal = 0; + $vectordbTotal = 0; + $databasesTotal = $data['databasesTotal']; + $requestsTotal = $data['requestsTotal']; + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' vectorsdb'; + + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'databaseId' => 'unique()', + 'name' => $name, + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $vectordbTotal += 1; + + $vectordbId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $vectordbTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $name = uniqid() . ' collection'; + + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/' . $vectordbId . '/collections', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'collectionId' => 'unique()', + 'name' => $name, + 'dimension' => 1536, + 'documentSecurity' => false, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + + $this->assertEquals($name, $response['body']['name']); + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $collectionsTotal += 1; + + $collectionId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $collectionsTotal -= 1; + $requestsTotal += 1; + } + } + + for ($i = 0; $i < self::CREATE; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/documents', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + [ + 'documentId' => 'unique()', + 'data' => [ + 'embeddings' => array_fill(0, 1536, 0.1), + 'metadata' => [ + 'name' => uniqid() . ' document', + 'value' => $i + ] + ] + ] + ); + + $this->assertNotEmpty($response['body']['$id']); + + $requestsTotal += 1; + $documentsTotal += 1; + + $documentId = $response['body']['$id']; + + if ($i < (self::CREATE / 2)) { + $response = $this->client->call( + Client::METHOD_DELETE, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/documents/' . $documentId, + array_merge([ + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), + ); + + $this->assertEmpty($response['body']); + + $documentsTotal -= 1; + $requestsTotal += 1; + } + } + + return array_merge($data, [ + 'vectordbId' => $vectordbId, + 'vectordbCollectionId' => $collectionId, + 'requestsTotal' => $requestsTotal, + 'databasesTotal' => $databasesTotal, + 'vectordbTotal' => $vectordbTotal, + 'vectordbCollectionsTotal' => $collectionsTotal, + 'vectordbDocumentsTotal' => $documentsTotal, + ]); + } + + #[Depends('testPrepareVectorsDBStats')] + #[Retry(count: 1)] + public function testVectorsDBStats(array $data): array + { + $vectordbId = $data['vectordbId']; + $collectionId = $data['vectordbCollectionId']; + $requestsTotal = $data['requestsTotal']; + $databasesTotal = $data['databasesTotal']; + $vectordbTotal = $data['vectordbTotal']; + $collectionsTotal = $data['vectordbCollectionsTotal']; + $documentsTotal = $data['vectordbDocumentsTotal']; + + $this->assertEventually(function () use ($requestsTotal, $vectordbTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1d', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertGreaterThanOrEqual(31, count($response['body'])); + $this->assertCount(1, $response['body']['requests']); + $this->assertCount(1, $response['body']['network']); + $this->assertEquals($requestsTotal, $response['body']['requests'][array_key_last($response['body']['requests'])]['value']); + $this->validateDates($response['body']['requests']); + // vectordbTotal should reflect only VectorsDB instances, not relational databases. + $this->assertEquals($vectordbTotal, $response['body']['vectordbDatabasesTotal']); + $this->assertEquals($documentsTotal, $response['body']['vectordbDocumentsTotal']); + }); + + $response = $this->client->call( + Client::METHOD_GET, + '/databases/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($databasesTotal, $response['body']['databases'][array_key_last($response['body']['databases'])]['value']); + $this->validateDates($response['body']['databases']); + + $this->assertEventually(function () use ($vectordbId, $collectionsTotal, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/vectorsdb/' . $vectordbId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($collectionsTotal, $response['body']['collections'][array_key_last($response['body']['collections'])]['value']); + $this->validateDates($response['body']['collections']); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + $this->assertEventually(function () use ($vectordbId, $collectionId, $documentsTotal) { + $response = $this->client->call( + Client::METHOD_GET, + '/vectorsdb/' . $vectordbId . '/collections/' . $collectionId . '/usage?range=30d', + $this->getConsoleHeaders() + ); + + $this->assertEquals($documentsTotal, $response['body']['documents'][array_key_last($response['body']['documents'])]['value']); + $this->validateDates($response['body']['documents']); + }); + + return $data; + } + + #[Depends('testVectorsDBStats')] public function testPrepareFunctionsStats(array $data): array { $executionTime = 0; @@ -1401,6 +1844,75 @@ class UsageTest extends Scope }); } + public function testEmbeddingsTextUsageDoesNotBreakProjectUsage(): void + { + // Trigger embeddings endpoint a few times so stats usage worker has data to aggregate + for ($i = 0; $i < 3; $i++) { + $response = $this->client->call( + Client::METHOD_POST, + '/vectorsdb/embeddings/text', + array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $this->getHeaders()), + [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'usage test text ' . $i, + ], + ] + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']['embeddings']); + $this->assertGreaterThan(0, $response['body']['total']); + } + + // Ensure project usage endpoint still responds correctly after embeddings calls + $this->assertEventually(function () { + $response = $this->client->call( + Client::METHOD_GET, + '/project/usage', + $this->getConsoleHeaders(), + [ + 'period' => '1h', + 'startDate' => self::getToday(), + 'endDate' => self::getTomorrow(), + ] + ); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('requests', $response['body']); + $this->assertArrayHasKey('network', $response['body']); + $this->assertArrayHasKey('executionsTotal', $response['body']); + + // New embeddings metrics should be present after calls above + $this->assertArrayHasKey('embeddingsText', $response['body']); + $this->assertArrayHasKey('embeddingsTextErrors', $response['body']); + $this->assertArrayHasKey('embeddingsTextTokens', $response['body']); + $this->assertArrayHasKey('embeddingsTextDuration', $response['body']); + $this->assertArrayHasKey('embeddingsTextTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextErrorsTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextTokensTotal', $response['body']); + $this->assertArrayHasKey('embeddingsTextDurationTotal', $response['body']); + + // Time-series arrays should be non-empty + $this->assertNotEmpty($response['body']['embeddingsText']); + $this->assertNotEmpty($response['body']['embeddingsTextTokens']); + $this->assertNotEmpty($response['body']['embeddingsTextDuration']); + $this->validateDates($response['body']['embeddingsText']); + $this->validateDates($response['body']['embeddingsTextTokens']); + $this->validateDates($response['body']['embeddingsTextDuration']); + + // Total scalars should be greater than 0 (or >= 0 for errors) + $this->assertGreaterThan(0, $response['body']['embeddingsTextTotal']); + $this->assertGreaterThanOrEqual(0, $response['body']['embeddingsTextErrorsTotal']); + $this->assertGreaterThan(0, $response['body']['embeddingsTextTokensTotal']); + $this->assertGreaterThan(0, $response['body']['embeddingsTextDurationTotal']); + }); + } + public function tearDown(): void { $this->projectId = ''; diff --git a/tests/e2e/Scopes/ApiDocumentsDB.php b/tests/e2e/Scopes/ApiDocumentsDB.php new file mode 100644 index 0000000000..9948b03971 --- /dev/null +++ b/tests/e2e/Scopes/ApiDocumentsDB.php @@ -0,0 +1,109 @@ +getSupportForAttributes()) { + return; + } $this->assertEventually(function () use ($databaseId, $containerId, $attributeKey) { $attribute = $this->client->call( Client::METHOD_GET, diff --git a/tests/e2e/Scopes/Scope.php b/tests/e2e/Scopes/Scope.php index a8152ef77e..8c62c0c14a 100644 --- a/tests/e2e/Scopes/Scope.php +++ b/tests/e2e/Scopes/Scope.php @@ -144,6 +144,14 @@ abstract class Scope extends TestCase return $this->getConsoleVariables()['supportForSchemas'] ?? true; } + /** + * Check if the database adapter supports attributes + */ + protected function getSupportForAttributes(): bool + { + return $this->getConsoleVariables()['supportForAttributes'] ?? true; + } + /** * Get the maximum index length supported by the database adapter */ diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index ea387cff6c..107dceaa5e 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2182,11 +2182,138 @@ class AccountCustomClientTest extends Scope ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', - ]); + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); + // Ensure user is authenticated + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + + // Same sign-in again, but this time with oauth2 token flow + $response = $this->client->call(Client::METHOD_GET, '/account/tokens/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', + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']); + + $oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY); + $oauthParams = []; + \parse_str($oauthParamsString, $oauthParams); + + $this->assertNotEmpty($oauthParams['secret']); + $this->assertNotEmpty($oauthParams['userId']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('success', $response['body']['result']); + + // Claim session + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => $oauthParams['userId'], + 'secret' => $oauthParams['secret'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('mock', $response['body']['provider']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + /** * Test for Failure when disabled */ diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index 5f8ac7dd94..628928914f 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -68,6 +68,31 @@ trait DatabasesBase return self::$databaseCache[$cacheKey]; } + /** + * Helper to create an attribute on a collection. + * + * @param string $databaseId + * @param string $collectionId + * @param string $type + * @param array $payload + * + * @return array + */ + protected function createAttribute(string $databaseId, string $collectionId, string $type, array $payload): array + { + return $this->client->call( + Client::METHOD_POST, + $this->getSchemaUrl($databaseId, $collectionId) . '/' . $type, + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + $payload + ); + } + + /** * Setup: Create database and collections * Uses static caching to avoid recreating resources @@ -150,75 +175,51 @@ trait DatabasesBase $data = $this->setupCollection(); $databaseId = $data['databaseId']; - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + if (!$this->getSupportForAttributes()) { + self::$attributesCache[$cacheKey] = $data; + return self::$attributesCache[$cacheKey]; + } + $title = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'title', 'size' => 256, 'required' => true, ]); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $description = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'description', 'size' => 512, 'required' => false, 'default' => '', ]); - $tagline = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $tagline = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'tagline', 'size' => 512, 'required' => false, 'default' => '', ]); - $releaseYear = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $releaseYear = $this->createAttribute($databaseId, $data['moviesId'], 'integer', [ 'key' => 'releaseYear', 'required' => true, 'min' => 1900, 'max' => 2200, ]); - $duration = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $duration = $this->createAttribute($databaseId, $data['moviesId'], 'integer', [ 'key' => 'duration', 'required' => false, 'min' => 60, ]); - $actors = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $actors = $this->createAttribute($databaseId, $data['moviesId'], 'string', [ 'key' => 'actors', 'size' => 256, 'required' => false, 'array' => true, ]); - $datetime = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $data['moviesId']) . '/datetime', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ + $datetime = $this->createAttribute($databaseId, $data['moviesId'], 'datetime', [ 'key' => 'birthDay', 'required' => false, ]); @@ -933,6 +934,10 @@ trait DatabasesBase public function testCreateAttributes(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } // Use dedicated collections for this test to avoid conflicts with setupAttributes() $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1182,6 +1187,10 @@ trait DatabasesBase public function testListAttributes(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $response = $this->client->call(Client::METHOD_GET, $this->getSchemaUrl($databaseId, $data['moviesId']), array_merge([ @@ -1210,6 +1219,10 @@ trait DatabasesBase public function testPatchAttribute(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -1275,6 +1288,10 @@ trait DatabasesBase public function testUpdateAttributeEnum(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), [ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -1332,6 +1349,10 @@ trait DatabasesBase public function testAttributeResponseModels(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $collection = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), array_merge([ @@ -2043,65 +2064,75 @@ trait DatabasesBase $this->assertEquals(201, $collection['headers']['status-code']); $collectionId = $collection['body']['$id']; - // Create attributes needed for index testing - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'title', 'size' => 256, 'required' => true]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create attributes needed for index testing (only when supported). + // DocumentsDB can still create indexes without a predefined schema. + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'description', 'size' => 512, 'required' => false, 'default' => '']); - $this->assertEquals(202, $description['headers']['status-code']); + $description = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'description', + 'size' => 512, + 'required' => false, + 'default' => '', + ]); + $this->assertEquals(202, $description['headers']['status-code']); - $tagline = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'tagline', 'size' => 512, 'required' => false, 'default' => '']); - $this->assertEquals(202, $tagline['headers']['status-code']); + $tagline = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'tagline', + 'size' => 512, + 'required' => false, + 'default' => '', + ]); + $this->assertEquals(202, $tagline['headers']['status-code']); - $releaseYear = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'releaseYear', 'required' => true, 'min' => 1900, 'max' => 2200]); - $this->assertEquals(202, $releaseYear['headers']['status-code']); + $releaseYear = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'releaseYear', + 'required' => true, + 'min' => 1900, + 'max' => 2200, + ]); + $this->assertEquals(202, $releaseYear['headers']['status-code']); - $actors = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'actors', 'size' => 256, 'required' => false, 'array' => true]); - $this->assertEquals(202, $actors['headers']['status-code']); + $actors = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'actors', + 'size' => 256, + 'required' => false, + 'array' => true, + ]); + $this->assertEquals(202, $actors['headers']['status-code']); - $birthDay = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/datetime', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'birthDay', 'required' => false]); - $this->assertEquals(202, $birthDay['headers']['status-code']); + $birthDay = $this->createAttribute($databaseId, $collectionId, 'datetime', [ + 'key' => 'birthDay', + 'required' => false, + ]); + $this->assertEquals(202, $birthDay['headers']['status-code']); - $integers = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'integers', 'required' => false, 'array' => true, 'min' => 10, 'max' => 99]); - $this->assertEquals(202, $integers['headers']['status-code']); + $integers = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'integers', + 'required' => false, + 'array' => true, + 'min' => 10, + 'max' => 99, + ]); + $this->assertEquals(202, $integers['headers']['status-code']); - $integers2 = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), ['key' => 'integers2', 'required' => false, 'array' => true, 'min' => 10, 'max' => 99]); - $this->assertEquals(202, $integers2['headers']['status-code']); + $integers2 = $this->createAttribute($databaseId, $collectionId, 'integer', [ + 'key' => 'integers2', + 'required' => false, + 'array' => true, + 'min' => 10, + 'max' => 99, + ]); + $this->assertEquals(202, $integers2['headers']['status-code']); - // Wait for attributes to be ready - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attributes to be ready + $this->waitForAllAttributes($databaseId, $collectionId); + } $titleIndex = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -2218,13 +2249,16 @@ trait DatabasesBase $this->getIndexAttributesParam() => ['description', 'tagline'], ]); - if ($this->getMaxIndexLength() < 1024) { - // Only SQL-based adapters (MariaDB, PostgreSQL) enforce byte-level index length limits - $this->assertEquals(400, $tooLong['headers']['status-code']); - $this->assertStringContainsString('Index length is longer than the maximum', $tooLong['body']['message']); - } else { - // MongoDB (maxIndexLength=1024) doesn't exceed the limit with 512+512 - $this->assertEquals(202, $tooLong['headers']['status-code']); + // documentsdb isn't aware of the size so it will create + if ($this->getSupportForAttributes()) { + if ($this->getMaxIndexLength() < 1024) { + // Only SQL-based adapters (MariaDB, PostgreSQL) enforce byte-level index length limits + $this->assertEquals(400, $tooLong['headers']['status-code']); + $this->assertStringContainsString('Index length is longer than the maximum', $tooLong['body']['message']); + } else { + // MongoDB (maxIndexLength=1024) doesn't exceed the limit with 512+512 + $this->assertEquals(202, $tooLong['headers']['status-code']); + } } $fulltextArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ @@ -2238,119 +2272,126 @@ trait DatabasesBase ]); $this->assertEquals(400, $fulltextArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $fulltextArray['body']['message']); + $errorMessage = $this->getSupportForAttributes() ? "Creating indexes on array attributes is not currently supported." : "There is already a fulltext index in the collection"; + $this->assertEquals($errorMessage, $fulltextArray['body']['message']); - $actorsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-actors', - 'type' => 'key', - $this->getIndexAttributesParam() => ['actors'], - ]); + if ($this->getSupportForAttributes()) { - $this->assertEquals(400, $actorsArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $actorsArray['body']['message']); + $actorsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-actors', + 'type' => 'key', + $this->getIndexAttributesParam() => ['actors'], + ]); - $twoLevelsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-ip-actors', - 'type' => 'key', - $this->getIndexAttributesParam() => ['releaseYear', 'actors'], // 2 levels - 'orders' => ['DESC', 'DESC'], - ]); + $this->assertEquals(400, $actorsArray['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $actorsArray['body']['message']); - $this->assertEquals(400, $twoLevelsArray['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $twoLevelsArray['body']['message']); + $twoLevelsArray = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-ip-actors', + 'type' => 'key', + $this->getIndexAttributesParam() => ['releaseYear', 'actors'], // 2 levels + 'orders' => ['DESC', 'DESC'], + ]); - $unknown = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'index-unknown', - 'type' => 'key', - $this->getIndexAttributesParam() => ['Unknown'], - ]); + $this->assertEquals(400, $twoLevelsArray['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $twoLevelsArray['body']['message']); - $this->assertEquals(400, $unknown['headers']['status-code']); - $this->assertStringContainsString('\'Unknown\' required for the index could not be found', $unknown['body']['message']); + $unknown = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'index-unknown', + 'type' => 'key', + $this->getIndexAttributesParam() => ['Unknown'], + ]); - $index1 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'integers-order', - 'type' => 'key', - $this->getIndexAttributesParam() => ['integers'], // array attribute - 'orders' => ['DESC'], // Check order is removed in API - ]); + $this->assertEquals(400, $unknown['headers']['status-code']); + $this->assertStringContainsString('\'Unknown\' required for the index could not be found', $unknown['body']['message']); + $index1 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'integers-order', + 'type' => 'key', + $this->getIndexAttributesParam() => ['integers'], // array attribute + 'orders' => ['DESC'], // Check order is removed in API + ]); - $this->assertEquals(400, $index1['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index1['body']['message']); + $this->assertEquals(400, $index1['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index1['body']['message']); - $index2 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'], - ]), [ - 'key' => 'integers-size', - 'type' => 'key', - $this->getIndexAttributesParam() => ['integers2'], // array attribute - ]); + $index2 = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'integers-size', + 'type' => 'key', + $this->getIndexAttributesParam() => ['integers2'], // array attribute + ]); - $this->assertEquals(400, $index2['headers']['status-code']); - $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index2['body']['message']); + $this->assertEquals(400, $index2['headers']['status-code']); + $this->assertEquals('Creating indexes on array attributes is not currently supported.', $index2['body']['message']); - if (!$this->getSupportForMultipleFulltextIndexes()) { - // Some databases only allow one fulltext index per collection - $this->assertEquals('There is already a fulltext index in the collection', $fulltextReleaseYear['body']['message']); - } else { - $this->assertEquals('Attribute "releaseYear" cannot be part of a fulltext index, must be of type string', $fulltextReleaseYear['body']['message']); - } + if (!$this->getSupportForMultipleFulltextIndexes()) { + // Some databases only allow one fulltext index per collection + $this->assertEquals('There is already a fulltext index in the collection', $fulltextReleaseYear['body']['message']); + } else { + $this->assertEquals('Attribute "releaseYear" cannot be part of a fulltext index, must be of type string', $fulltextReleaseYear['body']['message']); + } - /** - * Create Indexes by worker - */ - $this->waitForAllIndexes($databaseId, $collectionId); + /** + * Create Indexes by worker + */ + $this->waitForAllIndexes($databaseId, $collectionId); - $collectionResponse = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), []); - - $this->assertIsArray($collectionResponse['body']['indexes']); - $expectedIndexCount = $this->getMaxIndexLength() < 1024 ? 4 : 5; // MongoDB accepts tooLong index - $this->assertCount($expectedIndexCount, $collectionResponse['body']['indexes']); - $indexKeys = array_column($collectionResponse['body']['indexes'], 'key'); - $this->assertContains($titleIndex['body']['key'], $indexKeys); - $this->assertContains($releaseYearIndex['body']['key'], $indexKeys); - $this->assertContains($releaseWithDate1['body']['key'], $indexKeys); - $this->assertContains($releaseWithDate2['body']['key'], $indexKeys); - - $this->assertEventually(function () use ($databaseId, $collectionId) { - $collResp = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ + $collectionResponse = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-key' => $this->getProject()['apiKey'] - ])); + ]), []); - foreach ($collResp['body']['indexes'] as $index) { - $this->assertEquals('available', $index['status']); - } + $this->assertIsArray($collectionResponse['body']['indexes']); + $expectedIndexCount = $this->getMaxIndexLength() < 1024 ? 4 : 5; // MongoDB accepts tooLong index + $this->assertCount($expectedIndexCount, $collectionResponse['body']['indexes']); + $indexKeys = array_column($collectionResponse['body']['indexes'], 'key'); + $this->assertContains($titleIndex['body']['key'], $indexKeys); + $this->assertContains($releaseYearIndex['body']['key'], $indexKeys); + $this->assertContains($releaseWithDate1['body']['key'], $indexKeys); + $this->assertContains($releaseWithDate2['body']['key'], $indexKeys); - return true; - }, 60000, 500); + $this->assertEventually(function () use ($databaseId, $collectionId) { + $collResp = $this->client->call(Client::METHOD_GET, $this->getContainerUrl($databaseId, $collectionId), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + foreach ($collResp['body']['indexes'] as $index) { + $this->assertEquals('available', $index['status']); + } + + return true; + }, 60000, 500); + } } public function testGetIndexByKeyWithLengths(): void { + if (!$this->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } $data = $this->setupAttributes(); $databaseId = $data['databaseId']; $collectionId = $data['moviesId']; @@ -2544,6 +2585,7 @@ trait DatabasesBase $this->getRecordIdParam() => ID::unique(), 'data' => [ 'releaseYear' => 2020, // Missing title, expect an 400 error + 'birthDay' => null // adding null here as documentsdb will require it as for documentsdb this document will be created ], 'permissions' => [ Permission::read(Role::user($this->getUser()['$id'])), @@ -2563,7 +2605,11 @@ trait DatabasesBase $this->assertCount(2, $document1['body']['actors']); $this->assertEquals($document1['body']['actors'][0], 'Chris Evans'); $this->assertEquals($document1['body']['actors'][1], 'Samuel Jackson'); - $this->assertEquals($document1['body']['birthDay'], '1975-06-12T12:12:55.000+00:00'); + if ($this->getSupportForAttributes()) { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12T12:12:55.000+00:00'); + } else { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12 14:12:55+02:00'); + } $this->assertTrue(array_key_exists('$sequence', $document1['body'])); $this->assertIsString($document1['body']['$sequence']); @@ -2598,10 +2644,18 @@ trait DatabasesBase $this->assertCount(2, $document3['body']['actors']); $this->assertEquals($document3['body']['actors'][0], 'Tom Holland'); $this->assertEquals($document3['body']['actors'][1], 'Zendaya Maree Stoermer'); - $this->assertEquals($document3['body']['birthDay'], '1975-06-12T18:12:55.000+00:00'); // UTC for NY + if ($this->getSupportForAttributes()) { + $this->assertEquals($document3['body']['birthDay'], '1975-06-12T18:12:55.000+00:00'); // UTC for NY + } else { + $this->assertEquals($document1['body']['birthDay'], '1975-06-12 14:12:55+02:00'); + } $this->assertTrue(array_key_exists('$sequence', $document3['body'])); - $this->assertEquals(400, $document4['headers']['status-code']); + if ($this->getSupportForAttributes()) { + $this->assertEquals(400, $document4['headers']['status-code']); + } else { + $this->assertEquals(201, $document4['headers']['status-code']); + } } public function testUpsertDocument(): void @@ -2752,6 +2806,10 @@ trait DatabasesBase $this->assertEquals(204, $document['headers']['status-code']); // relationship behaviour - only test on databases that support relationships + /** @var array|null $person */ + $person = null; + /** @var array|null $library */ + $library = null; if ($this->getSupportForRelationships()) { $person = $this->client->call(Client::METHOD_POST, $this->getContainerUrl($databaseId), array_merge([ 'content-type' => 'application/json', @@ -3073,7 +3131,7 @@ trait DatabasesBase $this->assertEquals(204, $deleteResponse['headers']['status-code']); // upsertion for the related document without passing permissions - only for databases that support relationships - if ($this->getSupportForRelationships()) { + if ($this->getSupportForRelationships() && $person !== null && $library !== null) { // data should get added $newPersonId = ID::unique(); $personNoPerm = $this->client->call(Client::METHOD_PUT, $this->getRecordUrl($databaseId, $person['body']['$id'], $newPersonId), array_merge([ @@ -3264,6 +3322,10 @@ trait DatabasesBase public function testListDocumentsWithCache(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; $docIds = $data['documentIds']; @@ -3394,6 +3456,10 @@ trait DatabasesBase public function testListDocumentsCacheBustedByAttributeChange(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $data = $this->setupDocuments(); $databaseId = $data['databaseId']; $docIds = $data['documentIds']; @@ -3565,7 +3631,7 @@ trait DatabasesBase ]); $adapter = getenv('_APP_DB_ADAPTER'); - if ($adapter === 'mongodb') { + if ($adapter === 'mongodb' || !$this->getSupportForAttributes()) { $this->assertEquals(400, $response['headers']['status-code']); } else { $this->assertEquals(200, $response['headers']['status-code']); @@ -4030,34 +4096,43 @@ trait DatabasesBase ]); $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertEquals(0, $documents['body']['total']); - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::greaterThan('birthDay', '16/01/2024 12:00:00AM')->toString(), - ], - ]); + // for tablesdb/legacy it is full match , for docsdb inner pattern is matched + if ($this->getSupportForAttributes()) { + $this->assertEquals(0, $documents['body']['total']); + } else { + $this->assertGreaterThan(0, $documents['body']['total']); + } - $this->assertEquals(400, $documents['headers']['status-code']); - $this->assertEquals('Invalid query: Query value is invalid for attribute "birthDay"', $documents['body']['message']); + if ($this->getSupportForAttributes()) { + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('birthDay', '16/01/2024 12:00:00AM')->toString(), + ], + ]); - $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - ], $this->getHeaders()), [ - 'queries' => [ - Query::greaterThan('birthDay', '1960-01-01 10:10:10+02:30')->toString(), - ], - ]); + $this->assertEquals(400, $documents['headers']['status-code']); + $this->assertEquals('Invalid query: Query value is invalid for attribute "birthDay"', $documents['body']['message']); + + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::greaterThan('birthDay', '1960-01-01 10:10:10+02:30')->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual(2, count($documents['body'][$this->getRecordResource()])); + $birthDays = array_column($documents['body'][$this->getRecordResource()], 'birthDay'); + $this->assertContains('1975-06-12T12:12:55.000+00:00', $birthDays); + $this->assertContains('1975-06-12T18:12:55.000+00:00', $birthDays); + } - $this->assertEquals(200, $documents['headers']['status-code']); - $this->assertGreaterThanOrEqual(2, count($documents['body'][$this->getRecordResource()])); - $birthDays = array_column($documents['body'][$this->getRecordResource()], 'birthDay'); - $this->assertContains('1975-06-12T12:12:55.000+00:00', $birthDays); - $this->assertContains('1975-06-12T18:12:55.000+00:00', $birthDays); $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ 'content-type' => 'application/json', @@ -4701,6 +4776,10 @@ trait DatabasesBase public function testInvalidDocumentStructure(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('Attributes are not supported by this database adapter'); + return; + } $database = $this->client->call(Client::METHOD_POST, $this->getApiBasePath(), array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], @@ -5401,22 +5480,18 @@ trait DatabasesBase $this->assertEquals($collection['body'][$this->getSecurityResponseKey()], true); $collectionId = $collection['body']['$id']; + if ($this->getSupportForAttributes()) { + $attribute = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code'], 202); + $this->assertEquals('attribute', $attribute['body']['key']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'attribute', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEquals(202, $attribute['headers']['status-code'], 202); - $this->assertEquals('attribute', $attribute['body']['key']); - - // wait for db to add attribute - $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + // wait for db to add attribute + $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + } $index = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -5425,7 +5500,7 @@ trait DatabasesBase ]), [ 'key' => 'key_attribute', 'type' => 'key', - $this->getIndexAttributesParam() => [$attribute['body']['key']], + $this->getIndexAttributesParam() => ['attribute'], ]); $this->assertEquals(202, $index['headers']['status-code']); @@ -5591,21 +5666,17 @@ trait DatabasesBase $this->assertEquals($collection['body'][$this->getSecurityResponseKey()], false); $collectionId = $collection['body']['$id']; + if ($this->getSupportForAttributes()) { + $attribute = $this->createAttribute($databaseId, $collectionId, 'string', [ + 'key' => 'attribute', + 'size' => 64, + 'required' => true, + ]); + $this->assertEquals(202, $attribute['headers']['status-code'], 202); + $this->assertEquals('attribute', $attribute['body']['key']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'attribute', - 'size' => 64, - 'required' => true, - ]); - - $this->assertEquals(202, $attribute['headers']['status-code'], 202); - $this->assertEquals('attribute', $attribute['body']['key']); - - $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + $this->waitForAttribute($databaseId, $collectionId, 'attribute'); + } $index = $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ 'content-type' => 'application/json', @@ -5614,7 +5685,7 @@ trait DatabasesBase ]), [ 'key' => 'key_attribute', 'type' => 'key', - $this->getIndexAttributesParam() => [$attribute['body']['key']], + $this->getIndexAttributesParam() => ['attribute'], ]); $this->assertEquals(202, $index['headers']['status-code'], 'Index creation failed: ' . json_encode($index['body'] ?? [])); @@ -5981,21 +6052,18 @@ trait DatabasesBase $moviesId = $movies['body']['$id']; // create attribute - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $moviesId) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $moviesId, 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); - $this->assertEquals(202, $title['headers']['status-code']); - - // wait for database worker to create attributes - $this->waitForAttribute($databaseId, $moviesId, 'title'); + $this->assertEquals(202, $title['headers']['status-code']); + // wait for database worker to create attributes + $this->waitForAttribute($databaseId, $moviesId, 'title'); + } // add document $document = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $moviesId), array_merge([ 'content-type' => 'application/json', @@ -6060,6 +6128,11 @@ trait DatabasesBase public function testAttributeBooleanDefault(): void { + if (!$this->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + $data = $this->setupDatabase(); $databaseId = $data['databaseId']; @@ -6946,32 +7019,25 @@ trait DatabasesBase $this->assertEquals(201, $presidents['headers']['status-code']); $this->assertEquals($presidents['body']['name'], 'USA Presidents'); - // Create Attributes - $firstName = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $presidents['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'first_name', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $firstName['headers']['status-code']); + // Create Attributes (only for adapters that support attributes) + if ($this->getSupportForAttributes()) { + $firstName = $this->createAttribute($databaseId, $presidents['body']['$id'], 'string', [ + 'key' => 'first_name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $firstName['headers']['status-code']); - $lastName = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $presidents['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'last_name', - 'size' => 256, - 'required' => true, - ]); + $lastName = $this->createAttribute($databaseId, $presidents['body']['$id'], 'string', [ + 'key' => 'last_name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $lastName['headers']['status-code']); - $this->assertEquals(202, $lastName['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $presidents['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $presidents['body']['$id']); + } $document1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $presidents['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -7176,20 +7242,19 @@ trait DatabasesBase 'databaseId' => $databaseId, ]; - $longtext = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($data['databaseId'], $data['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'longtext', - 'size' => 100000000, - 'required' => false, - 'default' => null, - ]); + // Create attribute only on adapters that support attributes; DocumentsDB can still store the field schemalessly + if ($this->getSupportForAttributes()) { + $longtext = $this->createAttribute($data['databaseId'], $data['$id'], 'string', [ + 'key' => 'longtext', + 'size' => 100000000, + 'required' => false, + 'default' => null, + ]); - $this->assertEquals($longtext['headers']['status-code'], 202); + $this->assertEquals(202, $longtext['headers']['status-code']); - $this->waitForAttribute($data['databaseId'], $data['$id'], 'longtext'); + $this->waitForAttribute($data['databaseId'], $data['$id'], 'longtext'); + } for ($i = 0; $i < 10; $i++) { $this->client->call(Client::METHOD_POST, $this->getRecordUrl($data['databaseId'], $data['$id']), array_merge([ @@ -7257,17 +7322,20 @@ trait DatabasesBase ]); $collectionId = $collection['body']['$id']; - // Add integer attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'count', - 'required' => true, - ]); + // Add integer attribute only when supported; schemaless adapters (e.g. documentsdb) + // can still store the field without a predefined attribute. + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + ]); - $this->waitForAttribute($databaseId, $collectionId, 'count'); + $this->waitForAttribute($databaseId, $collectionId, 'count'); + } // Create document with initial count = 5 $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -7332,7 +7400,10 @@ trait DatabasesBase 'content-type' => 'application/json', 'x-appwrite-project' => $this->getProject()['$id'], ])); - $this->assertEquals(404, $notFound['headers']['status-code']); + $this->assertEquals( + $this->getSupportForAttributes() ? 404 : 200, + $notFound['headers']['status-code'] + ); // Test increment with value 0 $inc3 = $this->client->call(Client::METHOD_PATCH, $this->getRecordUrl($databaseId, $collectionId, $docId) . "/count/increment", array_merge([ @@ -7373,17 +7444,20 @@ trait DatabasesBase $collectionId = $collection['body']['$id']; - // Add integer attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'count', - 'required' => true, - ]); + // Add integer attribute only when supported; schemaless adapters can still + // store the field without a predefined attribute. + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId) . '/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'count', + 'required' => true, + ]); - $this->waitForAttribute($databaseId, $collectionId, 'count'); + $this->waitForAttribute($databaseId, $collectionId, 'count'); + } // Create document with initial count = 10 $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -9805,32 +9879,25 @@ trait DatabasesBase $this->assertEquals(201, $movies['headers']['status-code']); $this->assertEquals($movies['body']['name'], 'Movies'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $movies['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported; DocumentsDB can still store fields schemalessly) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $movies['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $genre = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $movies['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'genre', - 'size' => 256, - 'required' => true, - ]); + $genre = $this->createAttribute($databaseId, $movies['body']['$id'], 'string', [ + 'key' => 'genre', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $genre['headers']['status-code']); - $this->assertEquals(202, $genre['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $movies['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $movies['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $movies['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -9973,31 +10040,24 @@ trait DatabasesBase $this->assertEquals(201, $products['headers']['status-code']); $this->assertEquals($products['body']['name'], 'Products'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/string', 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->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $products['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $price = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/float', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'price', - 'required' => true, - ]); + $price = $this->createAttribute($databaseId, $products['body']['$id'], 'float', [ + 'key' => 'price', + 'required' => true, + ]); + $this->assertEquals(202, $price['headers']['status-code']); - $this->assertEquals(202, $price['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $products['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $products['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $products['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10105,32 +10165,25 @@ trait DatabasesBase $this->assertEquals(201, $employees['headers']['status-code']); $this->assertEquals($employees['body']['name'], 'Employees'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $employees['body']['$id']) . '/string', 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->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $employees['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $department = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $employees['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'department', - 'size' => 256, - 'required' => true, - ]); + $department = $this->createAttribute($databaseId, $employees['body']['$id'], 'string', [ + 'key' => 'department', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $department['headers']['status-code']); - $this->assertEquals(202, $department['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $employees['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $employees['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $employees['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10238,32 +10291,25 @@ trait DatabasesBase $this->assertEquals(201, $files['headers']['status-code']); $this->assertEquals($files['body']['name'], 'Files'); - // Create Attributes - $filename = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $files['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'filename', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $filename['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $filename = $this->createAttribute($databaseId, $files['body']['$id'], 'string', [ + 'key' => 'filename', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $filename['headers']['status-code']); - $type = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $files['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'type', - 'size' => 256, - 'required' => true, - ]); + $type = $this->createAttribute($databaseId, $files['body']['$id'], 'string', [ + 'key' => 'type', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $type['headers']['status-code']); - $this->assertEquals(202, $type['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $files['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $files['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $files['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10371,32 +10417,25 @@ trait DatabasesBase $this->assertEquals(201, $posts['headers']['status-code']); $this->assertEquals($posts['body']['name'], 'Posts'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $posts['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $posts['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $content = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $posts['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'content', - 'size' => 512, - 'required' => true, - ]); + $content = $this->createAttribute($databaseId, $posts['body']['$id'], 'string', [ + 'key' => 'content', + 'size' => 512, + 'required' => true, + ]); + $this->assertEquals(202, $content['headers']['status-code']); - $this->assertEquals(202, $content['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $posts['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $posts['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $posts['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10511,32 +10550,25 @@ trait DatabasesBase $this->assertEquals(201, $events['headers']['status-code']); $this->assertEquals($events['body']['name'], 'Events'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $events['body']['$id']) . '/string', 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->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $events['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $description = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $events['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'description', - 'size' => 512, - 'required' => true, - ]); + $description = $this->createAttribute($databaseId, $events['body']['$id'], 'string', [ + 'key' => 'description', + 'size' => 512, + 'required' => true, + ]); + $this->assertEquals(202, $description['headers']['status-code']); - $this->assertEquals(202, $description['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $events['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $events['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $events['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -10651,31 +10683,25 @@ trait DatabasesBase $this->assertEquals(201, $articles['headers']['status-code']); $this->assertEquals($articles['body']['name'], 'Articles'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $articles['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $articles['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $content = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $articles['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'content', - 'size' => 5000, - 'required' => true, - ]); - $this->assertEquals(202, $content['headers']['status-code']); + $content = $this->createAttribute($databaseId, $articles['body']['$id'], 'string', [ + 'key' => 'content', + 'size' => 5000, + 'required' => true, + ]); + $this->assertEquals(202, $content['headers']['status-code']); - // Wait for attributes to be available - $this->waitForAllAttributes($databaseId, $articles['body']['$id']); + // Wait for attributes to be available + $this->waitForAllAttributes($databaseId, $articles['body']['$id']); + } // Create first article $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $articles['body']['$id']), array_merge([ @@ -10836,32 +10862,25 @@ trait DatabasesBase $this->assertEquals(201, $tasks['headers']['status-code']); $this->assertEquals($tasks['body']['name'], 'Tasks'); - // Create Attributes - $title = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tasks['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $title['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $title = $this->createAttribute($databaseId, $tasks['body']['$id'], 'string', [ + 'key' => 'title', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $title['headers']['status-code']); - $status = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tasks['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $status = $this->createAttribute($databaseId, $tasks['body']['$id'], 'string', [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $status['headers']['status-code']); - $this->assertEquals(202, $status['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $tasks['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $tasks['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tasks['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -11009,32 +11028,25 @@ trait DatabasesBase $this->assertEquals(201, $orders['headers']['status-code']); $this->assertEquals($orders['body']['name'], 'Orders'); - // Create Attributes - $orderNumber = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $orders['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'orderNumber', - 'size' => 256, - 'required' => true, - ]); - $this->assertEquals(202, $orderNumber['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $orderNumber = $this->createAttribute($databaseId, $orders['body']['$id'], 'string', [ + 'key' => 'orderNumber', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $orderNumber['headers']['status-code']); - $status = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $orders['body']['$id']) . '/string', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 256, - 'required' => true, - ]); + $status = $this->createAttribute($databaseId, $orders['body']['$id'], 'string', [ + 'key' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $status['headers']['status-code']); - $this->assertEquals(202, $status['headers']['status-code']); - - // Wait for worker - $this->waitForAllAttributes($databaseId, $orders['body']['$id']); + // Wait for worker + $this->waitForAllAttributes($databaseId, $orders['body']['$id']); + } $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $orders['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -11181,30 +11193,24 @@ trait DatabasesBase $this->assertEquals(201, $products['headers']['status-code']); $this->assertEquals($products['body']['name'], 'Products'); - // Create Attributes - $name = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/string', 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->assertEquals(202, $name['headers']['status-code']); + // Create Attributes (only when supported) + if ($this->getSupportForAttributes()) { + $name = $this->createAttribute($databaseId, $products['body']['$id'], 'string', [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + $this->assertEquals(202, $name['headers']['status-code']); - $price = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $products['body']['$id']) . '/float', array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'price', - 'required' => true, - ]); - $this->assertEquals(202, $price['headers']['status-code']); + $price = $this->createAttribute($databaseId, $products['body']['$id'], 'float', [ + 'key' => 'price', + 'required' => true, + ]); + $this->assertEquals(202, $price['headers']['status-code']); - // Wait for attributes to be available - $this->waitForAllAttributes($databaseId, $products['body']['$id']); + // Wait for attributes to be available + $this->waitForAllAttributes($databaseId, $products['body']['$id']); + } // Create first product $row1 = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $products['body']['$id']), array_merge([ diff --git a/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php b/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php new file mode 100644 index 0000000000..1fdcc84d0c --- /dev/null +++ b/tests/e2e/Services/Databases/DocumentsDB/DocumentsDBIndexTest.php @@ -0,0 +1,362 @@ +client->call( + 'POST', + '/documentsdb', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Indexes', + ] + ); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $movies = $this->client->call( + 'POST', + '/documentsdb/' . $databaseId . '/collections', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + ] + ); + + $this->assertEquals(201, $movies['headers']['status-code']); + $moviesId = $movies['body']['$id']; + + $titleIndex = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'titleIndex', + 'type' => 'fulltext', + 'attributes' => ['title'], + ]); + + $this->assertEquals(202, $titleIndex['headers']['status-code']); + $this->assertEquals('titleIndex', $titleIndex['body']['key']); + $this->assertEquals('fulltext', $titleIndex['body']['type']); + $this->assertCount(1, $titleIndex['body']['attributes']); + $this->assertEquals('title', $titleIndex['body']['attributes'][0]); + + $releaseYearIndex = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYear', + 'type' => 'key', + 'attributes' => ['releaseYear'], + ]); + + $this->assertEquals(202, $releaseYearIndex['headers']['status-code']); + $this->assertEquals('releaseYear', $releaseYearIndex['body']['key']); + $this->assertEquals('key', $releaseYearIndex['body']['type']); + $this->assertCount(1, $releaseYearIndex['body']['attributes']); + $this->assertEquals('releaseYear', $releaseYearIndex['body']['attributes'][0]); + + $releaseWithDate1 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYearDated', + 'type' => 'key', + 'attributes' => ['releaseYear', '$createdAt', '$updatedAt'], + ]); + + $this->assertEquals(202, $releaseWithDate1['headers']['status-code']); + $this->assertEquals('releaseYearDated', $releaseWithDate1['body']['key']); + $this->assertEquals('key', $releaseWithDate1['body']['type']); + $this->assertCount(3, $releaseWithDate1['body']['attributes']); + $this->assertEquals('releaseYear', $releaseWithDate1['body']['attributes'][0]); + $this->assertEquals('$createdAt', $releaseWithDate1['body']['attributes'][1]); + $this->assertEquals('$updatedAt', $releaseWithDate1['body']['attributes'][2]); + + $releaseWithDate2 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'birthDay', + 'type' => 'key', + 'attributes' => ['birthDay'], + ]); + + $this->assertEquals(202, $releaseWithDate2['headers']['status-code']); + $this->assertEquals('birthDay', $releaseWithDate2['body']['key']); + $this->assertEquals('key', $releaseWithDate2['body']['type']); + $this->assertCount(1, $releaseWithDate2['body']['attributes']); + $this->assertEquals('birthDay', $releaseWithDate2['body']['attributes'][0]); + + // Failure cases + $fulltextReleaseYear = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'releaseYearDated', + 'type' => 'fulltext', + 'attributes' => ['releaseYear'], + ]); + $this->assertEquals(400, $fulltextReleaseYear['headers']['status-code']); + + $noAttributes = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'none', + 'type' => 'key', + 'attributes' => [], + ]); + $this->assertEquals(400, $noAttributes['headers']['status-code']); + + $duplicates = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'duplicate', + 'type' => 'fulltext', + 'attributes' => ['releaseYear', 'releaseYear'], + ]); + $this->assertEquals(400, $duplicates['headers']['status-code']); + + $tooLong = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'tooLong', + 'type' => 'key', + 'attributes' => ['description', 'tagline'], + ]); + $this->assertEquals(202, $tooLong['headers']['status-code']); + + $fulltextArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'ft', + 'type' => 'fulltext', + 'attributes' => ['actors'], + ]); + $this->assertEquals(400, $fulltextArray['headers']['status-code']); + + $actorsArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-actors', + 'type' => 'key', + 'attributes' => ['actors'], + ]); + $this->assertEquals(202, $actorsArray['headers']['status-code']); + + $twoLevelsArray = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-ip-actors', + 'type' => 'key', + 'attributes' => ['releaseYear', 'actors'], + 'orders' => ['DESC', 'DESC'], + ]); + $this->assertEquals(202, $twoLevelsArray['headers']['status-code']); + + $unknown = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'index-unknown', + 'type' => 'key', + 'attributes' => ['Unknown'], + ]); + $this->assertEquals(202, $unknown['headers']['status-code']); + + $index1 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'integers-order', + 'type' => 'key', + 'attributes' => ['integers'], + 'orders' => ['DESC'], + ]); + $this->assertEquals(202, $index1['headers']['status-code']); + + $index2 = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$moviesId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'integers-size', + 'type' => 'key', + 'attributes' => ['integers'], + ]); + $this->assertEquals(202, $index2['headers']['status-code']); + + // Let worker create indexes + sleep(2); + + $moviesWithIndexes = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$moviesId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertIsArray($moviesWithIndexes['body']['indexes']); + $this->assertCount(10, $moviesWithIndexes['body']['indexes']); + + $this->assertEventually(function () use ($databaseId, $moviesId) { + $movies = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$moviesId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + foreach ($movies['body']['indexes'] as $index) { + $this->assertEquals('available', $index['status']); + } + + return true; + }, 60000, 500); + } + + public function testGetIndexByKeyWithLengths(): void + { + $database = $this->client->call( + 'POST', + '/documentsdb', + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Index Lengths', + ] + ); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call( + 'POST', + "/documentsdb/{$databaseId}/collections", + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], + [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + ] + ); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthTestIndex', + 'type' => 'key', + 'attributes' => ['title', 'description'], + 'lengths' => [128, 200], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $index = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes/lengthTestIndex", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('lengthTestIndex', $index['body']['key']); + $this->assertEquals([128, 200], $index['body']['lengths']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthOverrideTestIndex', + 'type' => 'key', + 'attributes' => ['actors-new'], + 'lengths' => [Database::MAX_ARRAY_INDEX_LENGTH], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $index = $this->client->call('GET', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes/lengthOverrideTestIndex", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + $this->assertEquals([Database::MAX_ARRAY_INDEX_LENGTH], $index['body']['lengths']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthCountExceededIndex', + 'type' => 'key', + 'attributes' => ['title-not-throw-error'], + 'lengths' => [128, 128], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + $create = $this->client->call('POST', "/documentsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'key' => 'lengthTooLargeIndex', + 'type' => 'key', + 'attributes' => ['title', 'description', 'tagline', 'actors'], + 'lengths' => [256, 256, 256, 20], + ]); + $this->assertEquals(202, $create['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php b/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php new file mode 100644 index 0000000000..895cf67490 --- /dev/null +++ b/tests/e2e/Services/Databases/DocumentsDBConsoleClientTest.php @@ -0,0 +1,16 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $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' => 'InvalidDocumentDatabase', + ] + ); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('InvalidDocumentDatabase', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ); + $this->assertEquals(201, $publicMovies['headers']['status-code']); + + $privateMovies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $privateMovies['headers']['status-code']); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $publicCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $publicCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + $privateDocuments = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $privateCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $recordKey = $this->getRecordResource(); + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body'][$recordKey][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body'][$recordKey][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $publicCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $privateCollectionId), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $publicCollectionId, $publicDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ] + ); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['title']); + + $privateDocument = $this->client->call( + Client::METHOD_PATCH, + $this->getRecordUrl($databaseId, $privateCollectionId, $privateDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + ] + ); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call( + Client::METHOD_DELETE, + $this->getRecordUrl($databaseId, $publicCollectionId, $publicDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call( + Client::METHOD_DELETE, + $this->getRecordUrl($databaseId, $privateCollectionId, $privateDocumentId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ] + ); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $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' => 'GuestPermissionsWrite', + ] + ); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('GuestPermissionsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::create(Role::any()), + ], + $this->getSecurityParam() => true, + ] + ); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $moviesId), + [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Thor: Ragnarok', + ], + 'permissions' => [ + Permission::read(Role::any()), + ], + ] + ); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['title']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php new file mode 100644 index 0000000000..88e84b017d --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsMemberTest.php @@ -0,0 +1,238 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('random')))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 1, 1, 0], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 1, 1, 0], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 1, 1, 0], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 1, 1, 1], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('user1')))], 1, 1, 1], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 1, 1, 1], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 1, 1, 1], + ]; + } + + /** + * Setup database helper + */ + protected function setupDatabase(): array + { + $cacheKey = $this->getProject()['$id'] . '_' . static::class; + + if (!empty(self::$setupDatabaseCache[$cacheKey])) { + return self::$setupDatabaseCache[$cacheKey]; + } + + $this->createUsers(); + + $db = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + $this->getServerHeader(), + [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ] + ); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Movies', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Private Movies', + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::unique(), + 'name' => 'Document Only Movies', + 'permissions' => [], + $this->getSecurityParam() => true, + ] + ); + $this->assertEquals(201, $doconly['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + self::$setupDatabaseCache[$cacheKey] = [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId, + ]; + + return self::$setupDatabaseCache[$cacheKey]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount) + { + $data = $this->setupDatabase(); + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['public']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['private']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($databaseId, $collections['doconly']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + 'permissions' => $permissions, + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['public']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['private']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($databaseId, $collections['doconly']), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ] + ); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThanOrEqual($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php new file mode 100644 index 0000000000..db9adf9bb1 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/DocumentsDBPermissionsTeamTest.php @@ -0,0 +1,234 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call( + Client::METHOD_POST, + $this->getDatabaseUrl(), + $this->getServerHeader(), + [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ] + ); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($this->databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::custom('collection1'), + 'name' => 'Collection 1', + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ] + ); + $this->assertEquals(201, $collection1['headers']['status-code']); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call( + Client::METHOD_POST, + $this->getContainerUrl($this->databaseId), + $this->getServerHeader(), + [ + $this->getContainerIdParam() => ID::custom('collection2'), + 'name' => 'Collection 2', + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ], + ] + ); + $this->assertEquals(201, $collection2['headers']['status-code']); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database helper + */ + protected function setupDatabase(): array + { + $cacheKey = $this->getProject()['$id'] . '_' . static::class; + + if (!empty(self::$setupDatabaseCache[$cacheKey])) { + return self::$setupDatabaseCache[$cacheKey]; + } + + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $this->collections['collection1']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Lorem', + ], + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $this->collections['collection2']), + $this->getServerHeader(), + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Ipsum', + ], + ] + ); + $this->assertEquals(201, $response['headers']['status-code']); + + self::$setupDatabaseCache[$cacheKey] = $this->users; + + return self::$setupDatabaseCache[$cacheKey]; + } + + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success) + { + $users = $this->setupDatabase(); + + $documents = $this->client->call( + Client::METHOD_GET, + $this->getRecordUrl($this->databaseId, $collection), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ] + ); + + if ($success) { + $this->assertCount(1, $documents['body'][$this->getRecordResource()]); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success) + { + $users = $this->setupDatabase(); + + $documents = $this->client->call( + Client::METHOD_POST, + $this->getRecordUrl($this->databaseId, $collection), + [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], + [ + $this->getRecordIdParam() => ID::unique(), + 'data' => [ + 'title' => 'Ipsum', + ], + ] + ); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php new file mode 100644 index 0000000000..52ddcc8586 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsGuestTest.php @@ -0,0 +1,281 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestDB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestDB', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $privateMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + $privateDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body']['documents'][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body']['documents'][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ] + ]); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['metadata']['title']); + + $privateDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestPermsWrite', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestPermsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + ], + 'documentSecurity' => true + ]); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + 'permissions' => [ + Permission::read(Role::any()), + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['metadata']['title']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php new file mode 100644 index 0000000000..3043a42dd5 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsMemberTest.php @@ -0,0 +1,197 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 2, 2, 2], + [[Permission::read(Role::user(ID::custom('random')))], 3, 3, 2], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 4, 4, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 5, 5, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 6, 6, 2], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 7, 7, 2], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 8, 8, 3], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 9, 9, 4], + [[Permission::read(Role::user(ID::custom('user1')))], 10, 10, 5], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 11, 11, 6], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 12, 12, 7], + ]; + } + + /** + * Setup database + * + * Data providers lose object state so explicitly pass [$users, $collections] to each iteration + * + * @return array + * @throws \Exception + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Private Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Document Only Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + return [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId + ]; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[DataProvider('permissionsProvider')] + #[Depends('testSetupDatabase')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount, $data) + { + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php new file mode 100644 index 0000000000..11709ed729 --- /dev/null +++ b/tests/e2e/Services/Databases/Permissions/VectorsDBPermissionsTeamTest.php @@ -0,0 +1,200 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection1'), + 'name' => 'Collection 1', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ]); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection2'), + 'name' => 'Collection 2', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ] + ]); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database + * + * Data providers lose object state + * so explicitly pass $users to each iteration + * @return array $users + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection1'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection2'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + return $this->users; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[Depends('testSetupDatabase')] + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ]); + + if ($success) { + $this->assertCount(1, $documents['body']['documents']); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[Depends('testSetupDatabase')] + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/Transactions/ACIDBase.php b/tests/e2e/Services/Databases/Transactions/ACIDBase.php index 070b83734f..1a6ee83b33 100644 --- a/tests/e2e/Services/Databases/Transactions/ACIDBase.php +++ b/tests/e2e/Services/Databases/Transactions/ACIDBase.php @@ -47,18 +47,20 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add unique attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'email', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + // Add unique attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'email', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add unique index $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId), array_merge([ @@ -174,6 +176,11 @@ trait ACIDBase */ public function testConsistency(): void { + if (!$this->getSupportForAttributes()) { + $this->markTestSkipped('This adapter does not support attributes; schema constraint consistency cannot be tested.'); + return; + } + // Create database $database = $this->client->call(Client::METHOD_POST, $this->getDatabaseUrl(), array_merge([ 'content-type' => 'application/json', @@ -336,19 +343,21 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add counter attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => true, - 'min' => 0, - 'max' => 1000000 - ]); + if ($this->getSupportForAttributes()) { + // Add counter attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => true, + 'min' => 0, + 'max' => 1000000 + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create initial document with counter $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId), array_merge([ @@ -494,18 +503,20 @@ trait ACIDBase $collectionId = $collection['body']['$id']; - // Add attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'data', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + // Add attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'data', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create and commit transaction with multiple operations $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ diff --git a/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php b/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php new file mode 100644 index 0000000000..eb597e3488 --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/DocumentsDBACIDTest.php @@ -0,0 +1,18 @@ +assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -150,18 +152,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document first with API key $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -224,18 +228,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ 'content-type' => 'application/json', @@ -297,18 +303,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document with update permission at document level $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -376,18 +384,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create a document with delete permission at document level $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($this->getPermissionsDatabase(), $collection['body']['$id']), array_merge([ @@ -457,18 +467,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); // Add attribute - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -527,18 +539,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -597,18 +611,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -660,18 +676,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -723,18 +741,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1060,18 +1080,20 @@ trait TransactionPermissionsBase $this->assertEquals(201, $collection['headers']['status-code']); - $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'title', - 'size' => 255, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($this->getPermissionsDatabase(), $collection['body']['$id'], 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'title', + 'size' => 255, + 'required' => true, + ]); - $this->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($this->getPermissionsDatabase(), $collection['body']['$id']); + } // Create user 1 (fresh) and their transaction $user1 = $this->getUser(true); diff --git a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php index 479e4d5c68..c69c35f663 100644 --- a/tests/e2e/Services/Databases/Transactions/TransactionsBase.php +++ b/tests/e2e/Services/Databases/Transactions/TransactionsBase.php @@ -70,19 +70,21 @@ trait TransactionsBase $this->assertEquals(201, $collection['headers']['status-code']); self::$sharedCollectionId = $collection['body']['$id']; - // Create a standard 'name' attribute - $nameAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, self::$sharedCollectionId, "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->assertEquals(202, $nameAttr['headers']['status-code']); + // Create a standard 'name' attribute only if attributes are supported + if ($this->getSupportForAttributes()) { + $nameAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, self::$sharedCollectionId, "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->assertEquals(202, $nameAttr['headers']['status-code']); - $this->waitForAllAttributes($databaseId, self::$sharedCollectionId); + $this->waitForAllAttributes($databaseId, self::$sharedCollectionId); + } return self::$sharedCollectionId; } @@ -219,20 +221,22 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $attribute = $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, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $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->assertEquals(202, $attribute['headers']['status-code']); + $this->assertEquals(202, $attribute['headers']['status-code']); - // Wait for attribute to be created - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attribute to be created + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add valid operations $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ @@ -365,18 +369,20 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $attribute = $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, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $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->assertEquals(202, $attribute['headers']['status-code']); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->assertEquals(202, $attribute['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -517,17 +523,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attribute - $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' => 'value', - 'size' => 256, - 'required' => true, - ]); + 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' => 'value', + 'size' => 256, + 'required' => true, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Add operations $response = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl($transactionId) . "/operations", array_merge([ @@ -607,17 +615,18 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $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' => 'data', - 'size' => 256, - 'required' => false, - ]); - - $this->waitForAllAttributes($databaseId, $collectionId); + 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' => 'data', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction with minimum TTL (60 seconds) $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -697,17 +706,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $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' => 'value', - 'size' => 256, - 'required' => false, - ]); + 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' => 'value', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -819,19 +830,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $counterAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => true, - 'min' => 0, - 'max' => 1000000, - ]); - $this->assertEquals(202, $counterAttr['headers']['status-code']); + if ($this->getSupportForAttributes()) { + $counterAttr = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => true, + 'min' => 0, + 'max' => 1000000, + ]); + $this->assertEquals(202, $counterAttr['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -959,17 +972,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attribute - $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' => 'data', - 'size' => 256, - 'required' => false, - ]); + 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' => 'data', + 'size' => 256, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -1064,27 +1079,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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' => 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' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some initial documents for ($i = 1; $i <= 5; $i++) { @@ -1228,17 +1245,19 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes with constraints - $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' => 'email', - 'size' => 256, - 'required' => true, - ]); + 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' => 'email', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create unique index on email $this->client->call(Client::METHOD_POST, $this->getIndexUrl($databaseId, $collectionId, null), array_merge([ @@ -1361,18 +1380,20 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $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' => 'data', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $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' => 'data', + 'size' => 256, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $collectionId); + $this->waitForAllAttributes($databaseId, $collectionId); + } // Test double commit $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1485,18 +1506,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $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' => 'data', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $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' => 'data', + 'size' => 256, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1595,20 +1619,22 @@ trait TransactionsBase ['key' => 'data', 'type' => 'string', 'size' => 256, 'required' => false], ]; - foreach ($attributes as $attr) { - $type = $attr['type']; - unset($attr['type']); + if ($this->getSupportForAttributes()) { + foreach ($attributes as $attr) { + $type = $attr['type']; + unset($attr['type']); - $response = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, $type, null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), $attr); + $response = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, $type, null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), $attr); - $this->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals(202, $response['headers']['status-code']); + } + $this->waitForAllAttributes($databaseId, $collectionId); } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -1699,38 +1725,40 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); - $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->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); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create document outside transaction $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -1836,28 +1864,30 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2031,27 +2061,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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->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); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2175,27 +2207,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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->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); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create documents for bulk testing for ($i = 1; $i <= 3; $i++) { @@ -2299,28 +2333,30 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create one document outside transaction $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -2445,27 +2481,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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->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); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create documents for bulk testing for ($i = 1; $i <= 3; $i++) { @@ -2569,38 +2607,40 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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' => 'status', - 'size' => 256, - 'required' => false, - ]); + $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' => 'status', + 'size' => 256, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - 'min' => 1, - 'max' => 10, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + 'min' => 1, + 'max' => 10, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create an existing document outside transaction for testing $existingDoc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -2848,18 +2888,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Create attribute - $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, - ]); + if ($this->getSupportForAttributes()) { + // Create attribute + $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->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -2990,36 +3033,38 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'age', - 'required' => true, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + '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' => 'status', - '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' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some existing documents for ($i = 1; $i <= 3; $i++) { @@ -3170,37 +3215,39 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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' => 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' => 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' => 'priority', - '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' => 'priority', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 4; $i++) { @@ -3345,27 +3392,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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' => 'type', - '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' => 'type', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 3; $i++) { @@ -3507,27 +3556,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Create attributes - $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, - ]); + 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' => 'status', - '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' => 'status', + 'size' => 256, + 'required' => true, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create existing documents for ($i = 1; $i <= 5; $i++) { @@ -3663,28 +3714,31 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Add integer attributes - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add integer attributes + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'default' => 0, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'score', - 'required' => false, - 'default' => 100, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'score', + 'required' => false, + 'default' => 100, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial document $doc = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -3822,18 +3876,21 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; - // Add balance attribute - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'balance', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add balance attribute + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'balance', + 'required' => false, + 'default' => 0, + ]); + + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -3965,27 +4022,29 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $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' => 'status', - 'size' => 50, - 'required' => false, - ]); + 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' => 'status', + 'size' => 50, + 'required' => false, + ]); - $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' => 50, - 'required' => false, - ]); + $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' => 50, + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents for ($i = 1; $i <= 5; $i++) { @@ -4107,26 +4166,28 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $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' => 100, - 'required' => false, - ]); + 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' => 100, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'value', - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'value', + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create some initial documents $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $collectionId, null), array_merge([ @@ -4266,26 +4327,28 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add attributes - $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' => 'type', - 'size' => 50, - 'required' => false, - ]); + 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' => 'type', + 'size' => 50, + 'required' => false, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'priority', - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $collectionId, "integer", null), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'priority', + 'required' => false, + ]); + $this->waitForAllAttributes($databaseId, $collectionId); + } - $this->waitForAllAttributes($databaseId, $collectionId); // Create initial documents for ($i = 1; $i <= 10; $i++) { @@ -4405,20 +4468,22 @@ trait TransactionsBase $collectionId = $collection['body']['$id']; // Add required attribute - $attribute = $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, - ]); + if ($this->getSupportForAttributes()) { + $attribute = $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->assertEquals(202, $attribute['headers']['status-code']); + $this->assertEquals(202, $attribute['headers']['status-code']); - // Wait for attribute to be ready - $this->waitForAllAttributes($databaseId, $collectionId); + // Wait for attribute to be ready + $this->waitForAllAttributes($databaseId, $collectionId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -4852,27 +4917,29 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Add columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'default' => 0, + ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create initial row $row = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ @@ -4987,18 +5054,21 @@ trait TransactionsBase $tableId = $table['body']['$id']; - // Add balance column - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'balance', - 'required' => false, - 'default' => 0, - ]); + if ($this->getSupportForAttributes()) { + // Add balance column + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'balance', + 'required' => false, + 'default' => 0, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); // Create initial row $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ @@ -5095,17 +5165,19 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Add columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -5205,17 +5277,20 @@ trait TransactionsBase $tableId = $table['body']['$id']; - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'name', + 'size' => 50, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5309,17 +5384,20 @@ trait TransactionsBase $tableId = $table['body']['$id']; - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'status', - 'size' => 50, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'status', + 'size' => 50, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5427,17 +5505,20 @@ trait TransactionsBase 'required' => true, ]); - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'flag', - 'size' => 256, - 'required' => false, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'flag', + 'size' => 256, + 'required' => false, + ]); + + $this->waitForAllAttributes($databaseId, $tableId); + } - $this->waitForAllAttributes($databaseId, $tableId); $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ 'content-type' => 'application/json', @@ -5543,28 +5624,30 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Create columns - $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'name', - 'size' => 256, - 'required' => true, - ]); + if ($this->getSupportForAttributes()) { + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), 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, $tableId, 'integer'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'counter', - 'required' => false, - 'min' => 0, - 'max' => 10000, - ]); + $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'integer'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'counter', + 'required' => false, + 'min' => 0, + 'max' => 10000, + ]); - $this->waitForAllAttributes($databaseId, $tableId); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create transaction $transaction = $this->client->call(Client::METHOD_POST, $this->getTransactionUrl(), array_merge([ @@ -5687,19 +5770,21 @@ trait TransactionsBase $tableId = $table['body']['$id']; // Create array column - $column = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $this->getProject()['$id'], - 'x-appwrite-key' => $this->getProject()['apiKey'] - ]), [ - 'key' => 'items', - 'size' => 255, - 'required' => false, - 'array' => true, - ]); + if ($this->getSupportForAttributes()) { + $column = $this->client->call(Client::METHOD_POST, $this->getSchemaUrl($databaseId, $tableId, 'string'), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'items', + 'size' => 255, + 'required' => false, + 'array' => true, + ]); - $this->assertEquals(202, $column['headers']['status-code']); - $this->waitForAllAttributes($databaseId, $tableId); + $this->assertEquals(202, $column['headers']['status-code']); + $this->waitForAllAttributes($databaseId, $tableId); + } // Create initial row with some items $row = $this->client->call(Client::METHOD_POST, $this->getRecordUrl($databaseId, $tableId), array_merge([ diff --git a/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php b/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php new file mode 100644 index 0000000000..914c8d0c5b --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/VectorsDBACIDTest.php @@ -0,0 +1,528 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'AtomicityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection for the test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'AtomicityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a document outside the transaction + $existingDocumentId = 'existing_doc'; + $doc1 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $existingDocumentId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'], + ], + ]); + + $this->assertEquals(201, $doc1['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed. Response: ' . json_encode($transaction)); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id. Response body: ' . json_encode($transaction['body'])); + $transactionId = $transaction['body']['$id']; + + // Add operations - second create reuses an existing documentId and should cause the commit to fail + $response = $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' => 'txn_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'newuser@example.com'], + ], + [ + '$id' => $existingDocumentId, + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'duplicate@example.com'], + ], + [ + '$id' => 'txn_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'should-not-exist@example.com'], + ], + ], + 'transactionId' => $transactionId, + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Adding documents via normal route should succeed. Response: ' . json_encode($response['body'])); + + // Attempt to commit - should fail due to duplicate document ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test consistency - schema validation and constraints + */ + public function testConsistency(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConsistencyTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'ConsistencyTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $transactionId = $transaction['body']['$id']; + + // Stage operations with valid and invalid data (embedding length mismatch) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Valid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(2, 0.5), // Invalid dimensions + 'metadata' => ['name' => 'Invalid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.6), + 'metadata' => ['name' => 'Should Not Persist'], + ], + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Attempt to commit - should fail due to invalid embeddings + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertContains($response['headers']['status-code'], [400, 409, 500], 'Transaction commit should fail due to validation. Response: ' . json_encode($response['body'])); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test isolation - concurrent transactions on same data + */ + public function testIsolation(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsolationTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'IsolationTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document with status metadata + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['status' => 'pending'], + ], + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create first transaction + $transaction1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction1['headers']['status-code'], 'Transaction 1 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction1['body'], 'Transaction 1 response should have $id'); + $transactionId1 = $transaction1['body']['$id']; + + // Transaction 1: update status to approved + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'approved'], + ], + ], + ], + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + $this->assertEquals(200, $response1['headers']['status-code']); + + // Document should reflect the first transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('approved', $document['body']['metadata']['status']); + + // Create second transaction after first commit + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction2['headers']['status-code'], 'Transaction 2 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction2['body'], 'Transaction 2 response should have $id'); + $transactionId2 = $transaction2['body']['$id']; + + // Transaction 2: update status to declined + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'declined'], + ], + ], + ], + ]); + + // Commit second transaction and ensure isolation guarantees + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response2['headers']['status-code']); + + // Final document should reflect the second transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('declined', $document['body']['metadata']['status']); + } + + /** + * Test durability - committed data persists + */ + public function testDurability(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DurabilityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'DurabilityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction with multiple operations + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed'); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id'); + $transactionId = $transaction['body']['$id']; + + // Create two documents via normal route inside transaction + $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' => 'durable_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['data' => 'Important data 1'], + ], + [ + '$id' => 'durable_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['data' => 'Important data 2'], + ], + ], + 'transactionId' => $transactionId, + ]); + + // Update first document inside the same transaction + $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => ['data' => 'Updated important data 1'], + ], + 'transactionId' => $transactionId, + ]); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Commit should succeed. Response: ' . json_encode($response['body'])); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents exist and have correct data + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document1['headers']['status-code']); + $this->assertEquals('Updated important data 1', $document1['body']['metadata']['data']); + + $document2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_2", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document2['headers']['status-code']); + $this->assertEquals('Important data 2', $document2['body']['metadata']['data']); + + // Further update outside transaction to ensure persistence + $update = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'metadata' => ['data' => 'Modified outside transaction'], + ], + ]); + $this->assertEquals(200, $update['headers']['status-code']); + + // Verify the update persisted + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('Modified outside transaction', $document1['body']['metadata']['data']); + + // List all documents to verify total count + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(2, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php b/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php new file mode 100644 index 0000000000..f6f217ab69 --- /dev/null +++ b/tests/e2e/Services/Databases/Transactions/VectorsDBTransactionsConsoleClientTest.php @@ -0,0 +1,15 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + $this->assertEquals('vectorsdb', $database['body']['type']); + + return ['databaseId' => $database['body']['$id']]; + } + + #[Depends('testCreateCollectionSample')] + public function testCreateDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Build embedding vector matching collection dimensions (1536) + $vector = array_fill(0, 1536, 0.1); + $vector[0] = 1.0; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 1] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ] + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertNotEmpty($res['body']['$id']); + $documentId = $res['body']['$id']; + + // createdAt/updatedAt should be present and equal on initial create + $this->assertArrayHasKey('$createdAt', $res['body']); + $this->assertArrayHasKey('$updatedAt', $res['body']); + $this->assertNotEmpty($res['body']['$createdAt']); + $this->assertNotEmpty($res['body']['$updatedAt']); + $this->assertEquals($res['body']['$createdAt'], $res['body']['$updatedAt']); + + // Edge: invalid dimensions (vector too short) → expect 4xx + $badVec = [1.0, 0.0]; + $bad = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $badVec, + 'metadata' => ['type' => 'bad'] + ], + ]); + $this->assertGreaterThanOrEqual(400, $bad['headers']['status-code']); + $this->assertLessThan(500, $bad['headers']['status-code']); + + // Edge: invalid type values (strings) → expect 4xx + $strVec = ['1.0', '0.0', '0.0']; + $bad2 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $strVec, + 'metadata' => ['type' => 'bad-strings'] + ], + ]); + $this->assertGreaterThanOrEqual(400, $bad2['headers']['status-code']); + $this->assertLessThan(500, $bad2['headers']['status-code']); + + // Create another valid doc to verify list totals later + $res2 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 99] + ], + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $res2['headers']['status-code']); + $documentId2 = $res2['body']['$id']; + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => $documentId, + 'documentId2' => $documentId2, + 'createdAt' => $res['body']['$createdAt'], + 'updatedAt' => $res['body']['$updatedAt'], + ]; + } + + #[Depends('testCreateDocument')] + public function testGetDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($documentId, $res['body']['$id']); + + // Edge: missing document should return 404 + $missing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $missing['headers']['status-code']); + + return $data; + } + + #[Depends('testCreateDocument')] + public function testListDocuments(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::limit(5)->toString()] + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + + // Pagination: limit 1, then offset 1 + $page1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::limit(1)->toString(), + Query::orderAsc('$id')->toString() + ] + ]); + $this->assertEquals(200, $page1['headers']['status-code']); + $this->assertEquals(1, \count($page1['body']['documents'] ?? [])); + + $page2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::limit(1)->toString(), + Query::offset(1)->toString(), + Query::orderAsc('$id')->toString() + ] + ]); + $this->assertEquals(200, $page2['headers']['status-code']); + $this->assertEquals(1, \count($page2['body']['documents'] ?? [])); + + return $data; + } + + #[Depends('testCreateDocument')] + public function testUpsertDocument(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $vector = array_fill(0, 1536, 0.0); + // $vector[1] = 1.0; + + $upd = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 2] + ] + ]); + + $this->assertEquals(200, $upd['headers']['status-code']); + + // Verify update took effect + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals(2, $get['body']['metadata']['rank']); + // updatedAt should be greater or changed from earlier + $this->assertArrayHasKey('$updatedAt', $get['body']); + + return $data; + } + + #[Depends('testUpsertDocument')] + public function testUpdateDocument(array $data): array + { + // Upsert is used for update semantics + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $vector = array_fill(0, 1536, 0.0); + $vector[2] = 1.0; + + $upd = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['type' => 'sample', 'rank' => 3] + ] + ]); + + $this->assertEquals(200, $upd['headers']['status-code']); + + // Re-update to check idempotence and metadata replacement + $vector2 = array_fill(0, 1536, 0.0); + $vector2[3] = 1.0; + $upd2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + 'embeddings' => $vector2, + 'metadata' => ['type' => 'sample', 'rank' => 4] + ] + ]); + $this->assertEquals(200, $upd2['headers']['status-code']); + + // Verify updatedAt changed again + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertArrayHasKey('$updatedAt', $get2['body']); + + return $data; + } + + #[Depends('testUpdateDocument')] + public function testDocumentsVectorQueries(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create two more documents with distinct embeddings + $mk = function (array $vec, string $name) use ($databaseId, $collectionId) { + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $vec, + 'metadata' => ['name' => $name] + ], + 'permissions' => [Permission::read(Role::any())] + ]); + }; + + $vA = array_fill(0, 1536, 0.0); + $vA[0] = 1.0; // close to [1,0,0,...] + $vB = array_fill(0, 1536, 0.0); + $vB[1] = 1.0; // close to [0,1,0,...] + + $mk($vA, 'A'); + $mk($vB, 'B'); + + // Dot product + $dot = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorDot('embeddings', $vA)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $dot['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $dot['body']['total']); + + // Cosine + $cos = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', $vB)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $cos['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $cos['body']['total']); + + // Euclidean + $eu = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorEuclidean('embeddings', $vA)->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $eu['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $eu['body']['total']); + + // Combined vector + metadata filters + $combo = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', $vA)->toString(), + Query::notEqual('metadata', [['name' => 'B']])->toString(), + Query::limit(2)->toString() + ] + ]); + $this->assertEquals(200, $combo['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $combo['body']['total']); + + // Ordering with $id ascending combined with vector + $ordered = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorDot('embeddings', $vA)->toString(), + Query::orderAsc('$id')->toString(), + Query::limit(3)->toString() + ] + ]); + $this->assertEquals(200, $ordered['headers']['status-code']); + + return $data; + } + + #[Depends('testDocumentsVectorQueries')] + public function testDeleteDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + $documentId = $data['documentId']; + + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + + // GET after delete should be 404 + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + + // List should still work and reflect at least one less document compared to earlier pages (best-effort) + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::limit(5)->toString()] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + } + + #[Depends('testCreateCollectionSample')] + public function testDocumentPermissions(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create doc readable only by a specific user + $docId = ID::unique(); + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $docId, + 'data' => [ + 'embeddings' => $vector, + 'metadata' => ['scope' => 'private'] + ], + 'permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])) + ] + ]); + $this->assertEquals(201, $create['headers']['status-code']); + + $guest = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$docId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ]); + $this->assertEquals(404, $guest['headers']['status-code']); + + // GET with key should succeed regardless of document user-level permission + $withKey = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$docId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $withKey['headers']['status-code']); + } + + #[Depends('testCreateDatabase')] + public function testCreateCollection(array $data): array + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + $actors = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $actors['headers']['status-code']); + $this->assertEquals($actors['body']['name'], 'Actors'); + + return [ + 'databaseId' => $databaseId, + 'moviesId' => $movies['body']['$id'], + 'actorsId' => $actors['body']['$id'], + ]; + } + + public function testCreateDatabaseSample(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Sample VectorsDB' + ]); + + $this->assertNotEmpty($database['body']['$id']); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Sample VectorsDB', $database['body']['name']); + $this->assertEquals('vectorsdb', $database['body']['type']); + + return ['databaseId' => $database['body']['$id']]; + } + + #[Depends('testCreateDatabaseSample')] + public function testCreateCollectionSample(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Sample Collection', + 'dimension' => 1536, + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $this->assertEquals('Sample Collection', $collection['body']['name']); + $this->assertEquals(1536, $collection['body']['dimension']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collection['body']['$id'], + ]; + } + + public function testCreateMultipleDatabasesWithCollections(): array + { + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + $userId = $this->getUser()['$id']; + + /** + * Helper to create a database + */ + $createDatabase = function (string $name) use ($projectId, $apiKey) { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey + ], [ + 'databaseId' => ID::unique(), + 'name' => $name + ]); + + $this->assertEquals(201, $db['headers']['status-code']); + $this->assertEquals('vectorsdb', $db['body']['type']); + $this->assertEquals($name, $db['body']['name']); + $this->assertNotEmpty($db['body']['$id']); + + return $db['body']['$id']; + }; + + /** + * Helper to create a collection + */ + $createCollection = function (string $databaseId, string $name, int $dimensions = 1536) use ($projectId, $apiKey, $userId) { + $res = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey + ], [ + 'collectionId' => ID::unique(), + 'name' => $name, + 'documentSecurity' => true, + 'dimension' => $dimensions, + 'permissions' => [ + Permission::create(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertEquals($name, $res['body']['name']); + + return $res['body']['$id']; + }; + + /** + * === Database 1: MediaDB === + */ + $mediaDbId = $createDatabase('MediaDB'); + + $mediaCollections = ['Movies', 'Actors', 'Directors']; + $mediaCollectionIds = []; + + foreach ($mediaCollections as $col) { + $mediaCollectionIds[$col] = $createCollection($mediaDbId, $col); + } + + /** + * === Database 2: ContentDB === + */ + $contentDbId = $createDatabase('ContentDB'); + + $contentCollections = ['Articles', 'Authors']; + $contentCollectionIds = []; + + foreach ($contentCollections as $col) { + $contentCollectionIds[$col] = $createCollection($contentDbId, $col); + } + + // Create a tiny-dimension collection and insert a document to validate vector and object attributes + $tinyCollectionName = 'VectorsTiny'; + $tinyDimensions = 8; + $tinyCollectionId = $createCollection($mediaDbId, $tinyCollectionName, $tinyDimensions); + + return [ + 'databases' => [ + 'MediaDB' => [ + 'id' => $mediaDbId, + 'collections' => $mediaCollectionIds + ['VectorsTiny' => $tinyCollectionId], + ], + 'ContentDB' => [ + 'id' => $contentDbId, + 'collections' => $contentCollectionIds, + ], + ] + ]; + } + + public function testInvalidCollectionDimensions(): void + { + // dimensions = 0 -> expect 4xx + $bad0 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BadDims0' + ]); + $this->assertEquals(201, $bad0['headers']['status-code']); + $dbId = $bad0['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $dbId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'ZeroDims', + 'documentSecurity' => true, + 'dimension' => 0, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertGreaterThanOrEqual(400, $col['headers']['status-code']); + $this->assertLessThan(500, $col['headers']['status-code']); + + // dimensions too large -> expect 4xx + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $dbId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'HugeDims', + 'documentSecurity' => true, + 'dimension' => 16001, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertGreaterThanOrEqual(400, $col2['headers']['status-code']); + $this->assertLessThan(500, $col2['headers']['status-code']); + } + + public function testSingleDimensionVectorCollection(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'SingleDim' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'OneDim', + 'documentSecurity' => true, + 'dimension' => 1, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Create two docs with 1D embeddings + $id1 = ID::unique(); + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id1}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => ['embeddings' => [1.0]] + ]); + $id2 = ID::unique(); + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id2}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => ['embeddings' => [0.5]] + ]); + + // Query with vectorCosine + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [Query::vectorCosine('embeddings', [1.0])->toString(), Query::limit(2)->toString()] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $res['body']['total']); + } + + public function testVectorInvalidValues(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'InvalidVals' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Docs', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $badPayloads = [ + ['embeddings' => [INF, 0.0, 0.0]], + ['embeddings' => [-INF, 0.0, 0.0]], + ['embeddings' => [NAN, 0.0, 0.0]], + ['embeddings' => ['x' => 1.0, 'y' => 0.0, 'z' => 0.0]], + ['embeddings' => [1.0, null, 0.0]], + ['embeddings' => [[1.0], [0.0], [0.0]]], + ['embeddings' => [true, false, true]], + ['embeddings' => [1.0, '2.0', 3.0]], + (function () { + $v = []; + $v[0] = 1.0; + $v[2] = 1.0; + return ['embeddings' => $v]; + })(), + ]; + + foreach ($badPayloads as $payload) { + $resp = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => $payload + ]); + $this->assertGreaterThanOrEqual(400, $resp['headers']['status-code']); + $this->assertLessThan(500, $resp['headers']['status-code']); + } + } + + public function testVectorAllZerosAndQuery(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'ZerosDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Zeros', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))], + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'data' => ['embeddings' => [0.0, 0.0, 0.0]] ]); + + $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/" . ID::unique(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'data' => ['embeddings' => [1.0, 0.0, 0.0]] ]); + + $results = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertEquals(200, $results['headers']['status-code']); + $this->assertGreaterThan(0, $results['body']['total']); + } + + public function testVectorMultipleQueriesRejection(): void + { + // Create a simple DB and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'MultiQueryDB' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Two vector queries simultaneously should fail + $fail = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString(), + Query::vectorEuclidean('embeddings', [1.0, 0.0, 0.0])->toString() + ] + ]); + $this->assertGreaterThanOrEqual(400, $fail['headers']['status-code']); + $this->assertLessThan(500, $fail['headers']['status-code']); + } + + public function testVectorQueryOnNonVectorAttribute(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'NonVec' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Query on non-vector attribute 'metadata' should fail + $fail = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('metadata', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertGreaterThanOrEqual(400, $fail['headers']['status-code']); + $this->assertLessThan(500, $fail['headers']['status-code']); + } + + public function testVectorEmptyQueryCollection(): void + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'EmptyQ' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + $col = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'collectionId' => ID::unique(), 'name' => 'Docs', 'documentSecurity' => true, 'dimension' => 3, 'permissions' => [Permission::create(Role::user($this->getUser()['$id']))] ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'queries' => [Query::vectorCosine('embeddings', [1.0, 0.0, 0.0])->toString()] ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals(0, $res['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testCreateIndexes(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['moviesId']; + + // HNSW Euclidean + $idxEuclidean = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxEuclidean['headers']['status-code']); + + // HNSW Dot (Inner Product) + $idxDot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxDot['headers']['status-code']); + + // HNSW Cosine + $idxCosine = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $idxCosine['headers']['status-code']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'indexes' => ['embedding_euclidean', 'embedding_dot', 'embedding_cosine'] + ]; + } + + #[Depends('testCreateIndexes')] + public function testListIndexes(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $list['headers']['status-code']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes'] ?? []); + foreach ($data['indexes'] as $expectedKey) { + $this->assertContains($expectedKey, $keys); + } + } + + #[Depends('testCreateIndexes')] + public function testGetIndexByKey(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $keysToTypes = [ + 'embedding_euclidean' => Database::INDEX_HNSW_EUCLIDEAN, + 'embedding_dot' => Database::INDEX_HNSW_DOT, + 'embedding_cosine' => Database::INDEX_HNSW_COSINE, + ]; + + foreach ($keysToTypes as $key => $type) { + $res = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/{$key}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($key, $res['body']['key']); + $this->assertEquals($type, $res['body']['type']); + } + } + +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php new file mode 100644 index 0000000000..abe4d4968b --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesConsoleClientTest.php @@ -0,0 +1,312 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Vector Console DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Vector Console DB', $database['body']['name']); + $this->assertTrue($database['body']['enabled']); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + /** + * Test when database is disabled but can still create collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Vector Console DB Updated', + 'enabled' => false, + ]); + + $this->assertFalse($database['body']['enabled']); + + $tvShows = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'TvShows', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + /** + * Test when collection is disabled but can still modify collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $movies['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies', + 'enabled' => false, + ]); + + $this->assertEquals(201, $tvShows['headers']['status-code']); + $this->assertEquals($tvShows['body']['name'], 'TvShows'); + + return ['moviesId' => $movies['body']['$id'], 'databaseId' => $databaseId, 'tvShowsId' => $tvShows['body']['$id']]; + } + + #[Depends('testCreateCollection')] + public function testListCollection(array $data) + { + /** + * Test when database is disabled but can still call list collections + */ + $databaseId = $data['databaseId']; + + $collections = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $collections['headers']['status-code']); + $this->assertEquals(2, $collections['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testGetCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test when database and collection are disabled but can still call get collection + */ + $collection = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testUpdateCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test When database and collection are disabled but can still call update collection + */ + $collection = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies Updated', + 'enabled' => false + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies Updated', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testDeleteCollection(array $data) + { + $databaseId = $data['databaseId']; + $tvShowsId = $data['tvShowsId']; + + /** + * Test when database and collection are disabled but can still call delete collection + */ + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $tvShowsId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertEquals($response['body'], ""); + } + + #[Depends('testCreateCollection')] + public function testGetDatabaseUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(11, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsNumeric($response['body']['collectionsTotal']); + $this->assertIsArray($response['body']['collections']); + $this->assertIsArray($response['body']['documents']); + } + + + #[Depends('testCreateCollection')] + public function testGetCollectionUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/randomCollectionId/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(3, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsArray($response['body']['documents']); + } + + #[Depends('testCreateCollection')] + public function testGetCollectionLogs(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php new file mode 100644 index 0000000000..b27cb420b4 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomClientTest.php @@ -0,0 +1,205 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $databaseId = $database['body']['$id']; + + // Collection aliases write to create, update, delete + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ], + ]); + + $moviesId = $movies['body']['$id']; + + $this->assertContains(Permission::create(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + + // VectorsDB uses fixed schema (embeddings, metadata). No attribute creation needed. + + // Document aliases write to update, delete + $document1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertNotContains(Permission::create(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + + /** + * Test for FAILURE + */ + + // Document does not allow create permission + $document2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertEquals(400, $document2['headers']['status-code']); + } + + public function testUpdateWithoutPermission(): array + { + // As a part of preparation, we get ID of currently logged-in user + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + + $userId = $response['body']['$id']; + + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::custom('permissionCheckDatabase'), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + + $databaseId = $database['body']['$id']; + // Create collection + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::custom('permissionCheck'), + 'name' => 'permissionCheck', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Creating document by server, give read permission to our user + some other user + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => ID::custom('permissionCheckDocument'), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'AppwriteBeginner'], + ], + 'permissions' => [ + Permission::read(Role::user(ID::custom('user2'))), + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Update document + // This is the point of this test. We should be allowed to do this action, and it should not fail on permission check + $response = $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'AppwriteExpert'], + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Get name of the document, should be the new one + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("AppwriteExpert", $response['body']['metadata']['name']); + + // Cleanup to prevent collision with other tests + // Delete collection + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + + // Wait for database worker to finish deleting collection + sleep(2); + + // Make sure collection has been deleted + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + $this->assertEquals(404, $response['headers']['status-code']); + + return []; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php new file mode 100644 index 0000000000..9564b76079 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/DatabasesCustomServerTest.php @@ -0,0 +1,964 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('first'), + 'name' => 'Test 1', + ]); + $this->assertEquals(201, $db1['headers']['status-code']); + $this->assertEquals('Test 1', $db1['body']['name']); + $this->assertEquals('vectorsdb', $db1['body']['type']); + // Validate database response model fields on create + $this->assertArrayHasKey('$id', $db1['body']); + $this->assertArrayHasKey('$createdAt', $db1['body']); + $this->assertArrayHasKey('$updatedAt', $db1['body']); + $this->assertArrayHasKey('enabled', $db1['body']); + + $db2 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('second'), + 'name' => 'Test 2', + ]); + $this->assertEquals(201, $db2['headers']['status-code']); + $this->assertEquals('Test 2', $db2['body']['name']); + $this->assertEquals('vectorsdb', $db2['body']['type']); + + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['databases']); + $this->assertArrayHasKey('$id', $list['body']['databases'][0]); + $this->assertArrayHasKey('name', $list['body']['databases'][0]); + $this->assertArrayHasKey('type', $list['body']['databases'][0]); + + return ['databaseId' => $db1['body']['$id']]; + } + + #[Depends('testListDatabases')] + public function testGetDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($databaseId, $res['body']['$id']); + $this->assertEquals('Test 1', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testUpdateDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testDeleteDatabase(array $data): void + { + $databaseId = $data['databaseId']; + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + + public function testCollectionsCRUD(): array + { + // Create database for collections tests + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Collections DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create two collections + $col1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1', + 'collectionId' => ID::custom('first'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col1['headers']['status-code']); + // Validate collection response model on create + $this->assertArrayHasKey('$id', $col1['body']); + $this->assertArrayHasKey('$createdAt', $col1['body']); + $this->assertArrayHasKey('$updatedAt', $col1['body']); + $this->assertArrayHasKey('enabled', $col1['body']); + $this->assertArrayHasKey('documentSecurity', $col1['body']); + $this->assertArrayHasKey('dimension', $col1['body']); + + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 2', + 'collectionId' => ID::custom('second'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col2['headers']['status-code']); + $this->assertArrayHasKey('$id', $col2['body']); + $this->assertArrayHasKey('$createdAt', $col2['body']); + $this->assertArrayHasKey('$updatedAt', $col2['body']); + + // List collections + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['collections']); + $this->assertArrayHasKey('$id', $list['body']['collections'][0]); + $this->assertArrayHasKey('name', $list['body']['collections'][0]); + $this->assertArrayHasKey('dimension', $list['body']['collections'][0]); + + // Get collection + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($col1['body']['$id'], $get['body']['$id']); + $this->assertEquals('Test 1', $get['body']['name']); + $this->assertEquals(3, $get['body']['dimension']); + + // Update collection (name only) + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $upd['body']['name']); + $this->assertArrayHasKey('$updatedAt', $upd['body']); + + // Delete collection + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $col2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $col1['body']['$id'], + ]; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionMore(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Update collection name and dimensions + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Renamed', + 'dimension' => 4, + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $upd['body']['name']); + $this->assertEquals(4, $upd['body']['dimension']); + + // Read back to confirm + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $get['body']['name']); + $this->assertEquals(4, $get['body']['dimension']); + + return $data; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionEnabledFlag(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Disable collection + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + // Re-enable collection + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + return $data; + } + + public function testUpdateDatabaseNameAndEnabled(): void + { + // Create isolated database for this test to avoid ordering conflicts + $create = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Update DB', + ]); + $this->assertEquals(201, $create['headers']['status-code']); + $databaseId = $create['body']['$id']; + + // Update name + $rename = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + ]); + $this->assertEquals(200, $rename['headers']['status-code']); + $this->assertEquals('Test DB Renamed', $rename['body']['name']); + + // Toggle enabled off then on + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testRecreateIndex(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create a new index variant + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean_v2', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + // Ensure it exists + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean_v2', $get['body']['key']); + + // Delete it + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testIndexesCRUD(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create indexes + $eu = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $eu['headers']['status-code']); + + $dot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $dot['headers']['status-code']); + + $cos = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $cos['headers']['status-code']); + + // List indexes + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['indexes']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes']); + $this->assertContains('embedding_euclidean', $keys); + $this->assertContains('embedding_dot', $keys); + $this->assertContains('embedding_cosine', $keys); + + // Get index by key + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean', $get['body']['key']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $get['body']['type']); + + // Delete index + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + sleep(4); + // Ensure it's gone + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + } + + public function testBulkCreate(): array + { + // Setup: create isolated database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BulkDBCreate' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColCreate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['group' => 'bulkA'], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['group' => 'bulkB'], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + $this->assertNotEmpty($ids[0]); + $this->assertNotEmpty($ids[1]); + + // Fetch and validate persisted data via GET + foreach ($ids as $i => $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($id, $get['body']['$id']); + $this->assertIsArray($get['body']['embeddings']); + $this->assertCount(3, $get['body']['embeddings']); + $this->assertArrayHasKey('group', $get['body']['metadata']); + } + + return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'bulkIds' => $ids ]; + } + + public function testCreateTextEmbeddingsSuccessAndErrors(): void + { + // Setup new database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'EmbedDB', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'EmbedCol', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Success: two embeddings + $this->assertEventually(function () { + $ok = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'hello world', + 'second sentence', + ], + ]); + $this->assertEquals(200, $ok['headers']['status-code']); + $this->assertIsInt($ok['body']['total'] ?? 0); + $this->assertEquals(2, $ok['body']['total']); + $this->assertIsArray($ok['body']['embeddings']); + $this->assertCount(2, $ok['body']['embeddings']); + foreach ($ok['body']['embeddings'] as $embed) { + $this->assertIsString($embed['model']); + $this->assertIsInt($embed['dimension']); + $this->assertIsArray($embed['embedding']); + $this->assertGreaterThan(0, count($embed['embedding'])); + $this->assertArrayHasKey('error', $embed); + } + }, 3000, 100); + + // Error: missing texts payload + $missingTexts = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], []); + $this->assertEquals(400, $missingTexts['headers']['status-code']); + + // Error: invalid texts item type (must be strings) + $invalidItem = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'valid text', + 123, // invalid, not a string + ], + ]); + $this->assertEquals(400, $invalidItem['headers']['status-code']); + + // Error: unknown embedding model + $unknownModel = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'nonexistent-model', + 'texts' => ['hello'], + ]); + $this->assertEquals(400, $unknownModel['headers']['status-code']); + } + + public function testBulkUpsert(): void + { + // Setup fresh db/collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpsert' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpsert', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['group' => 'bulkA', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.2, 0.8, 0.0], + 'metadata' => ['group' => 'bulkB', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + $this->assertTrue($res['body']['documents'][0]['metadata']['updated']); + $this->assertTrue($res['body']['documents'][1]['metadata']['updated']); + + // Fetch and validate updated content + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['updated']); + } + + // Perform another bulk upsert to mutate the same documents + $docs2 = [ + [ 'embeddings' => [0.6, 0.4, 0.0], 'metadata' => ['updatedAgain' => true] ], + [ 'embeddings' => [0.3, 0.7, 0.0], 'metadata' => ['updatedAgain' => true] ], + ]; + $res2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs2 + ]); + $this->assertEquals(200, $res2['headers']['status-code']); + $this->assertIsArray($res2['body']['documents']); + $this->assertCount(2, $res2['body']['documents']); + + // Fetch again and assert second update persisted + $ids2 = array_map(fn ($d) => $d['$id'], $res2['body']['documents']); + foreach ($ids2 as $id) { + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertTrue($get2['body']['metadata']['updatedAgain']); + } + } + + public function testBulkUpdate(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpdate' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpdate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ 'metadata' => ['bulkUpdated' => true] ], + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + foreach ($res['body']['documents'] as $doc) { + $this->assertTrue($doc['metadata']['bulkUpdated']); + } + + // Fetch by IDs and assert update persisted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['bulkUpdated']); + } + } + + public function testBulkDelete(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBDelete' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColDelete', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + + // Ensure they are deleted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + } + + public function testCustomTimestamps(): void + { + // Setup: create database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'TimestampTestDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'TimestampTestCollection', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Test: Create document with custom timestamps using PUT (upsert) + $customCreatedAt = '1970-01-01T00:00:00.000+00:00'; + $customUpdatedAt = '1970-01-01T00:00:00.000+00:00'; + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $documentId = ID::unique(); + + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $documentId, + 'data' => [ + '$createdAt' => $customCreatedAt, + '$updatedAt' => $customUpdatedAt, + 'embeddings' => $vector, + 'metadata' => ['test' => 'custom_timestamps'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + $documentId = $doc['body']['$id']; + $this->assertNotEmpty($documentId); + + // Verify timestamps were set correctly + $this->assertEquals($customCreatedAt, $doc['body']['$createdAt'], 'CreatedAt should match custom timestamp'); + $this->assertEquals($customUpdatedAt, $doc['body']['$updatedAt'], 'UpdatedAt should match custom timestamp'); + + // Fetch document and verify timestamps persist + $fetched = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEquals($customCreatedAt, $fetched['body']['$createdAt'], 'CreatedAt should persist after fetch'); + $this->assertEquals($customUpdatedAt, $fetched['body']['$updatedAt'], 'UpdatedAt should persist after fetch'); + + // Test: Update document with new custom timestamps + $newCustomUpdatedAt = '2000-01-01T12:00:00.000+00:00'; + $vector2 = array_fill(0, 1536, 0.0); + $vector2[1] = 1.0; + + $updated = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + '$createdAt' => $customCreatedAt, // Keep original createdAt + '$updatedAt' => $newCustomUpdatedAt, // Update updatedAt + 'embeddings' => $vector2, + 'metadata' => ['test' => 'updated_timestamps'] + ] + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($customCreatedAt, $updated['body']['$createdAt'], 'CreatedAt should remain unchanged'); + $this->assertEquals($newCustomUpdatedAt, $updated['body']['$updatedAt'], 'UpdatedAt should be updated to new custom timestamp'); + + // Final verification + $final = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $final['headers']['status-code']); + $this->assertEquals($customCreatedAt, $final['body']['$createdAt'], 'CreatedAt should persist through updates'); + $this->assertEquals($newCustomUpdatedAt, $final['body']['$updatedAt'], 'UpdatedAt should reflect the latest custom timestamp'); + } + +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php new file mode 100644 index 0000000000..9335b7f55b --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsGuestTest.php @@ -0,0 +1,280 @@ +authorization)) { + return $this->authorization; + } + + $this->authorization = new Authorization(); + + return $this->authorization; + } + + public function createCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestDB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestDB', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $publicMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + $privateMovies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + + $publicCollection = ['id' => $publicMovies['body']['$id']]; + $privateCollection = ['id' => $privateMovies['body']['$id']]; + + return [ + 'databaseId' => $databaseId, + 'publicCollectionId' => $publicCollection['id'], + 'privateCollectionId' => $privateCollection['id'], + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())]], + [[Permission::read(Role::users())]], + [[Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())]], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())]], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())]], + ]; + } + + #[DataProvider('permissionsProvider')] + public function testReadDocuments($permissions) + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions, + ]); + + $this->assertEquals(201, $publicResponse['headers']['status-code']); + $this->assertEquals(201, $privateResponse['headers']['status-code']); + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + $privateDocuments = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(1, $publicDocuments['body']['total']); + $this->assertEquals($permissions, $publicDocuments['body']['documents'][0]['$permissions']); + + if (\in_array(Permission::read(Role::any()), $permissions)) { + $this->assertEquals(1, $privateDocuments['body']['total']); + $this->assertEquals($permissions, $privateDocuments['body']['documents'][0]['$permissions']); + } else { + $this->assertEquals(0, $privateDocuments['body']['total']); + } + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocument() + { + $data = $this->createCollection(); + $publicCollectionId = $data['publicCollectionId']; + $privateCollectionId = $data['privateCollectionId']; + $databaseId = $data['databaseId']; + + $roles = $this->getAuthorization()->getRoles(); + $this->getAuthorization()->cleanRoles(); + + $publicResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ] + ]); + + $publicDocumentId = $publicResponse['body']['$id']; + $this->assertEquals(201, $publicResponse['headers']['status-code']); + + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(401, $privateResponse['headers']['status-code']); + + // Create a document in private collection with API key so we can test that update and delete are also not allowed + $privateResponse = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + + $this->assertEquals(201, $privateResponse['headers']['status-code']); + $privateDocumentId = $privateResponse['body']['$id']; + + $publicDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(200, $publicDocument['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $publicDocument['body']['metadata']['title']); + + $privateDocument = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + $publicDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $publicCollectionId . '/documents/' . $publicDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(204, $publicDocument['headers']['status-code']); + + $privateDocument = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $privateCollectionId . '/documents/' . $privateDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(401, $privateDocument['headers']['status-code']); + + foreach ($roles as $role) { + $this->getAuthorization()->addRole($role); + } + } + + public function testWriteDocumentWithPermissions() + { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'VectorGuestPermsWrite', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('VectorGuestPermsWrite', $database['body']['name']); + + $databaseId = $database['body']['$id']; + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + ], + 'documentSecurity' => true + ]); + + $moviesId = $movies['body']['$id']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Thor: Ragnarok'], + ], + 'permissions' => [ + Permission::read(Role::any()), + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $this->assertEquals('Thor: Ragnarok', $document['body']['metadata']['title']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php new file mode 100644 index 0000000000..cbc2add857 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsMemberTest.php @@ -0,0 +1,196 @@ + $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + ]; + } + + public static function permissionsProvider(): array + { + return [ + [[Permission::read(Role::any())], 1, 1, 1], + [[Permission::read(Role::users())], 2, 2, 2], + [[Permission::read(Role::user(ID::custom('random')))], 3, 3, 2], + [[Permission::read(Role::user(ID::custom('lorem'))), Permission::update(Role::user('lorem')), Permission::delete(Role::user('lorem'))], 4, 4, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 5, 5, 2], + [[Permission::read(Role::user(ID::custom('dolor'))), Permission::read(Role::user('lorem')), Permission::update(Role::user('dolor')), Permission::delete(Role::user('dolor'))], 6, 6, 2], + [[Permission::update(Role::any()), Permission::delete(Role::any())], 7, 7, 2], + [[Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())], 8, 8, 3], + [[Permission::read(Role::any()), Permission::update(Role::users()), Permission::delete(Role::users())], 9, 9, 4], + [[Permission::read(Role::user(ID::custom('user1')))], 10, 10, 5], + [[Permission::read(Role::user(ID::custom('user1'))), Permission::read(Role::user(ID::custom('user1')))], 11, 11, 6], + [[Permission::read(Role::users()), Permission::update(Role::users()), Permission::delete(Role::users())], 12, 12, 7], + ]; + } + + /** + * Setup database + * + * Data providers lose object state so explicitly pass [$users, $collections] to each iteration + * + * @return array + * @throws \Exception + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $databaseId = $db['body']['$id']; + + $public = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $public['headers']['status-code']); + $this->collections = ['public' => $public['body']['$id']]; + + $private = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Private Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::users()), + Permission::create(Role::users()), + Permission::update(Role::users()), + Permission::delete(Role::users()), + ], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['private'] = $private['body']['$id']; + + $doconly = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::unique(), + 'name' => 'Document Only Movies', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $private['headers']['status-code']); + $this->collections['doconly'] = $doconly['body']['$id']; + + return [ + 'users' => $this->users, + 'collections' => $this->collections, + 'databaseId' => $databaseId + ]; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[DataProvider('permissionsProvider')] + #[Depends('testSetupDatabase')] + public function testReadDocuments($permissions, $anyCount, $usersCount, $docOnlyCount, $data) + { + $users = $data['users']; + $collections = $data['collections']; + $databaseId = $data['databaseId']; + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 0.0, 1.0], + 'metadata' => ['title' => 'Lorem'], + ], + 'permissions' => $permissions + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + /** + * Check "any" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['public'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($anyCount, $documents['body']['total']); + + /** + * Check "users" permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['private'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($usersCount, $documents['body']['total']); + + /** + * Check "user:user1" document only permission collection + */ + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collections['doconly'] . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users['user1']['session'], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals($docOnlyCount, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php new file mode 100644 index 0000000000..be1800b654 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsScope.php @@ -0,0 +1,87 @@ +client->call(Client::METHOD_POST, '/account', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-dev-key' => $this->getProject()['devKey'] ?? '', + ], [ + 'userId' => $id, + 'email' => $email, + 'password' => $password + ]); + + $this->assertEquals(201, $user['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $session = $session['cookies']['a_session_' . $this->getProject()['$id']]; + + $user = [ + '$id' => $user['body']['$id'], + 'email' => $user['body']['email'], + 'session' => $session, + ]; + $this->users[$id] = $user; + + return $user; + } + + public function getCreatedUser(string $id): array + { + return $this->users[$id] ?? []; + } + + public function createTeam(string $id, string $name): array + { + $team = $this->client->call(Client::METHOD_POST, '/teams', $this->getServerHeader(), [ + 'teamId' => $id, + 'name' => $name + ]); + $this->teams[$id] = $team['body']; + + return $team['body']; + } + + public function addToTeam(string $user, string $team, array $roles = []): array + { + $membership = $this->client->call(Client::METHOD_POST, '/teams/' . $team . '/memberships', $this->getServerHeader(), [ + 'teamId' => $team, + 'email' => $this->getCreatedUser($user)['email'], + 'roles' => $roles, + 'url' => 'http://localhost:5000/join-us#title' + ]); + + return [ + 'user' => $membership['body']['userId'], + 'membership' => $membership['body']['$id'] + ]; + } + + public function getServerHeader(): array + { + return [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php new file mode 100644 index 0000000000..4091ea7140 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Permissions/DatabasesPermissionsTeamTest.php @@ -0,0 +1,199 @@ + $this->createTeam('team1', 'Team 1'), + 'team2' => $this->createTeam('team2', 'Team 2'), + ]; + } + + public function createUsers(): array + { + return [ + 'user1' => $this->createUser('user1', 'lorem@ipsum.com'), + 'user2' => $this->createUser('user2', 'dolor@ipsum.com'), + 'user3' => $this->createUser('user3', 'sit@ipsum.com'), + ]; + } + + public function createCollections($teams) + { + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', $this->getServerHeader(), [ + 'databaseId' => $this->databaseId, + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + + $collection1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection1'), + 'name' => 'Collection 1', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team1']['$id'])), + Permission::create(Role::team($teams['team1']['$id'], 'admin')), + Permission::update(Role::team($teams['team1']['$id'], 'admin')), + Permission::delete(Role::team($teams['team1']['$id'], 'admin')), + ], + ]); + + $this->collections['collection1'] = $collection1['body']['$id']; + + $collection2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections', $this->getServerHeader(), [ + 'collectionId' => ID::custom('collection2'), + 'name' => 'Collection 2', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::team($teams['team2']['$id'])), + Permission::create(Role::team($teams['team2']['$id'], 'owner')), + Permission::update(Role::team($teams['team2']['$id'], 'owner')), + Permission::delete(Role::team($teams['team2']['$id'], 'owner')), + ] + ]); + + $this->collections['collection2'] = $collection2['body']['$id']; + + return $this->collections; + } + + /* + * $success = can $user read from $collection + * [$user, $collection, $success] + */ + public static function readDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', true], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', true], + ]; + } + + /* + * $success = can $user write to $collection + * [$user, $collection, $success] + */ + public static function writeDocumentsProvider(): array + { + return [ + ['user1', 'collection1', true], + ['user2', 'collection1', false], + ['user3', 'collection1', false], + ['user1', 'collection2', false], + ['user2', 'collection2', true], + ['user3', 'collection2', false], + ]; + } + + /** + * Setup database + * + * Data providers lose object state + * so explicitly pass $users to each iteration + * @return array $users + */ + public function testSetupDatabase(): array + { + $this->createUsers(); + $this->createTeams(); + + $this->addToTeam('user1', 'team1', ['admin']); + $this->addToTeam('user2', 'team2', ['owner']); + + // user3 in both teams but with no roles + $this->addToTeam('user3', 'team1'); + $this->addToTeam('user3', 'team2'); + + $this->createCollections($this->teams); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection1'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Lorem'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $this->collections['collection2'] . '/documents', $this->getServerHeader(), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + return $this->users; + } + + /** + * Data provider params are passed before test dependencies. + */ + #[Depends('testSetupDatabase')] + #[DataProvider('readDocumentsProvider')] + public function testReadDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ]); + + if ($success) { + $this->assertCount(1, $documents['body']['documents']); + } else { + $this->assertEquals(401, $documents['headers']['status-code']); + } + } + + #[Depends('testSetupDatabase')] + #[DataProvider('writeDocumentsProvider')] + public function testWriteDocuments($user, $collection, $success, $users) + { + $documents = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $this->databaseId . '/collections/' . $collection . '/documents', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $users[$user]['session'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.2, 0.3, 0.5], + 'metadata' => ['title' => 'Ipsum'], + ], + ]); + + if ($success) { + $this->assertEquals(201, $documents['headers']['status-code']); + } else { + // 401 if user is a part of team, 404 otherwise + $this->assertContains($documents['headers']['status-code'], [401, 404]); + } + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php new file mode 100644 index 0000000000..aa8d87eb8e --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/ACIDTest.php @@ -0,0 +1,528 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'AtomicityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection for the test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'AtomicityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a document outside the transaction + $existingDocumentId = 'existing_doc'; + $doc1 = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $existingDocumentId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'], + ], + ]); + + $this->assertEquals(201, $doc1['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed. Response: ' . json_encode($transaction)); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id. Response body: ' . json_encode($transaction['body'])); + $transactionId = $transaction['body']['$id']; + + // Add operations - second create reuses an existing documentId and should cause the commit to fail + $response = $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' => 'txn_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'newuser@example.com'], + ], + [ + '$id' => $existingDocumentId, + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'duplicate@example.com'], + ], + [ + '$id' => 'txn_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'should-not-exist@example.com'], + ], + ], + 'transactionId' => $transactionId, + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Adding documents via normal route should succeed. Response: ' . json_encode($response['body'])); + + // Attempt to commit - should fail due to duplicate document ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test consistency - schema validation and constraints + */ + public function testConsistency(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConsistencyTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'ConsistencyTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $transactionId = $transaction['body']['$id']; + + // Stage operations with valid and invalid data (embedding length mismatch) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Valid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(2, 0.5), // Invalid dimensions + 'metadata' => ['name' => 'Invalid User'], + ], + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.6), + 'metadata' => ['name' => 'Should Not Persist'], + ], + ], + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Attempt to commit - should fail due to invalid embeddings + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertContains($response['headers']['status-code'], [400, 409, 500], 'Transaction commit should fail due to validation. Response: ' . json_encode($response['body'])); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test isolation - concurrent transactions on same data + */ + public function testIsolation(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'IsolationTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'IsolationTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document with status metadata + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['status' => 'pending'], + ], + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create first transaction + $transaction1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction1['headers']['status-code'], 'Transaction 1 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction1['body'], 'Transaction 1 response should have $id'); + $transactionId1 = $transaction1['body']['$id']; + + // Transaction 1: update status to approved + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'approved'], + ], + ], + ], + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + $this->assertEquals(200, $response1['headers']['status-code']); + + // Document should reflect the first transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('approved', $document['body']['metadata']['status']); + + // Create second transaction after first commit + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction2['headers']['status-code'], 'Transaction 2 creation should succeed'); + $this->assertArrayHasKey('$id', $transaction2['body'], 'Transaction 2 response should have $id'); + $transactionId2 = $transaction2['body']['$id']; + + // Transaction 2: update status to declined + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['status' => 'declined'], + ], + ], + ], + ]); + + // Commit second transaction and ensure isolation guarantees + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response2['headers']['status-code']); + + // Final document should reflect the second transaction's update + $document = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('declined', $document['body']['metadata']['status']); + } + + /** + * Test durability - committed data persists + */ + public function testDurability(): void + { + // Create database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DurabilityTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'DurabilityTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction with multiple operations + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(201, $transaction['headers']['status-code'], 'Transaction creation should succeed'); + $this->assertArrayHasKey('$id', $transaction['body'], 'Transaction response should have $id'); + $transactionId = $transaction['body']['$id']; + + // Create two documents via normal route inside transaction + $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' => 'durable_doc_1', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['data' => 'Important data 1'], + ], + [ + '$id' => 'durable_doc_2', + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['data' => 'Important data 2'], + ], + ], + 'transactionId' => $transactionId, + ]); + + // Update first document inside the same transaction + $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => ['data' => 'Updated important data 1'], + ], + 'transactionId' => $transactionId, + ]); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code'], 'Commit should succeed. Response: ' . json_encode($response['body'])); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents exist and have correct data + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document1['headers']['status-code']); + $this->assertEquals('Updated important data 1', $document1['body']['metadata']['data']); + + $document2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_2", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $document2['headers']['status-code']); + $this->assertEquals('Important data 2', $document2['body']['metadata']['data']); + + // Further update outside transaction to ensure persistence + $update = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'metadata' => ['data' => 'Modified outside transaction'], + ], + ]); + $this->assertEquals(200, $update['headers']['status-code']); + + // Verify the update persisted + $document1 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/durable_doc_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals('Modified outside transaction', $document1['body']['metadata']['data']); + + // List all documents to verify total count + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(2, $documents['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php new file mode 100644 index 0000000000..70150a3bc8 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsBase.php @@ -0,0 +1,2371 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionTestDatabase' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Test creating a transaction with default TTL + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('status', $response['body']); + $this->assertArrayHasKey('operations', $response['body']); + $this->assertArrayHasKey('expiresAt', $response['body']); + $this->assertEquals('pending', $response['body']['status']); + $this->assertEquals(0, $response['body']['operations']); + + $transactionId1 = $response['body']['$id']; + + // Test creating a transaction with custom TTL + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 900 + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('pending', $response['body']['status']); + + $expiresAt = new \DateTime($response['body']['expiresAt']); + $now = new \DateTime(); + $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThan(800, $diff); + $this->assertLessThan(1000, $diff); + + $transactionId2 = $response['body']['$id']; + + // Test invalid TTL values + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 30 // Below minimum + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 4000 // Above maximum + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test adding operations to a transaction + */ + public function testCreateOperations(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionOperationsTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create a collection for testing + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionOperationsTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Add valid operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc1', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test Document 1'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc2', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Test Document 2'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(2, $response['body']['operations']); + + // Test adding more operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'doc1', + 'data' => [ + 'metadata' => ['name' => 'Updated Document 1'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(3, $response['body']['operations']); + + // Test invalid database ID + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => 'invalid_database', + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code'], 'Invalid database should return 404. Got: ' . json_encode($response['body'])); + + // Test invalid collection ID + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => 'invalid_collection', + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + /** + * Test committing a transaction + */ + public function testCommit(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionCommitTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionCommitTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Add operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc1', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Test Document 1'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc2', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Test Document 2'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'doc1', + 'data' => [ + 'metadata' => ['name' => 'Updated Document 1'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(3, $response['body']['operations']); + + // Commit the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('committed', $response['body']['status']); + + // Verify documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(2, $documents['body']['total']); + + // Verify the update was applied + $doc1Found = false; + foreach ($documents['body']['documents'] as $doc) { + if ($doc['$id'] === 'doc1') { + $this->assertEquals('Updated Document 1', $doc['metadata']['name']); + $doc1Found = true; + } + } + $this->assertTrue($doc1Found, 'Document doc1 should exist with updated name'); + + // Test committing already committed transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test rolling back a transaction + */ + public function testRollback(): void + { + // Create database first + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'TransactionRollbackTestDB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create a collection for rollback test + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TransactionRollbackTest', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Add operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'rollback_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['value' => 'Should not exist'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Rollback the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('failed', $response['body']['status']); + + // Verify no documents were created + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(0, $documents['body']['total']); + } + + /** + * Test transaction expiration + */ + public function testTransactionExpiration(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ExpirationTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction with minimum TTL (60 seconds) + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'ttl' => 60 + ]); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Add operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Should expire'] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Verify transaction was created with correct expiration + $txnDetails = $this->client->call(Client::METHOD_GET, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $txnDetails['headers']['status-code']); + $this->assertEquals('pending', $txnDetails['body']['status']); + + // Verify expiration time is approximately 60 seconds from now + $expiresAt = new \DateTime($txnDetails['body']['expiresAt']); + $now = new \DateTime(); + $diff = $expiresAt->getTimestamp() - $now->getTimestamp(); + $this->assertGreaterThan(55, $diff); + $this->assertLessThan(65, $diff); + } + + /** + * Test maximum operations per transaction + */ + public function testTransactionSizeLimit(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'SizeLimitTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [Permission::create(Role::any())], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Try to add operations exceeding the limit (assuming limit is 100) + // We'll add 50 operations twice to test incremental limit + $operations = []; + for ($i = 0; $i < 50; $i++) { + $operations[] = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.001)), + 'metadata' => ['value' => 'Test ' . $i] + ] + ]; + } + + // First batch should succeed + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => $operations + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(50, $response['body']['operations']); + + // Second batch of 50 more operations + $operations = []; + for ($i = 50; $i < 100; $i++) { + $operations[] = [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => 'doc_' . $i, + 'action' => 'create', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.001)), + 'metadata' => ['value' => 'Test ' . $i] + ] + ]; + } + + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => $operations + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals(100, $response['body']['operations']); + + // Try to add one more operation - should fail + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => 'doc_overflow', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['value' => 'This should fail'] + ] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test concurrent transactions with conflicting operations + */ + public function testConcurrentTransactionConflicts(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ConflictTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create initial document + $doc = $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' => 'shared_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['counter' => 100] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create two transactions + $txn1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $txn2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId1 = $txn1['body']['$id']; + $transactionId2 = $txn2['body']['$id']; + + // Both transactions try to update the same document + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId1}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['counter' => 200] + ] + ] + ] + ]); + + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'shared_doc', + 'data' => [ + 'metadata' => ['counter' => 300] + ] + ] + ] + ]); + + // Commit first transaction + $response1 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId1}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response1['headers']['status-code']); + + // Commit second transaction - should fail with conflict + $response2 = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response2['headers']['status-code']); // Conflict + + // Verify the document has the value from first transaction + $doc = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/shared_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $doc['body']['metadata']['counter']); + } + + /** + * Test deleting a document that's being updated in a transaction + */ + public function testDeleteDocumentDuringTransaction(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DeleteConflictDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document + $doc = $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' => 'target_doc', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Original'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add update operation to transaction + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'target_doc', + 'data' => [ + 'metadata' => ['data' => 'Updated in transaction'] + ] + ] + ] + ]); + + // Delete the document outside of transaction + $response = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/target_doc", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Try to commit transaction - should fail because document no longer exists + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Conflict + } + + /** + * Test bulk operations in transactions + */ + public function testBulkOperations(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkOpsDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create some initial documents + for ($i = 1; $i <= 5; $i++) { + $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' => 'existing_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 + ($i * 0.01)), + 'metadata' => [ + 'name' => 'Existing ' . $i, + 'category' => 'old' + ] + ] + ]); + } + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add bulk operations + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + // Bulk create + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkCreate', + 'data' => [ + [ + '$id' => 'bulk_1', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['name' => 'Bulk 1', 'category' => 'new'] + ], + [ + '$id' => 'bulk_2', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['name' => 'Bulk 2', 'category' => 'new'] + ], + [ + '$id' => 'bulk_3', + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['name' => 'Bulk 3', 'category' => 'new'] + ], + ] + ], + // Bulk update + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkUpdate', + 'data' => [ + 'queries' => [Query::equal('metadata', [['category' => 'old']])->toString()], + 'data' => ['metadata' => ['category' => 'updated']] + ] + ], + // Bulk delete + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'bulkDelete', + 'data' => [ + 'queries' => [Query::equal('$id', ['existing_5'])->toString()] + ] + ] + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Verify results + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + // Should have 7 documents (5 existing - 1 deleted + 3 new) + $this->assertEquals(7, $documents['body']['total']); + + // Check categories were updated + $oldCategoryCount = 0; + $updatedCategoryCount = 0; + $newCategoryCount = 0; + + foreach ($documents['body']['documents'] as $doc) { + $category = $doc['metadata']['category'] ?? null; + switch ($category) { + case 'old': + $oldCategoryCount++; + break; + case 'updated': + $updatedCategoryCount++; + break; + case 'new': + $newCategoryCount++; + break; + } + } + + $this->assertEquals(0, $oldCategoryCount); + $this->assertEquals(4, $updatedCategoryCount); // 4 existing docs updated + $this->assertEquals(3, $newCategoryCount); // 3 new docs + } + + /** + * Test transaction with mixed success and failure operations + */ + public function testPartialFailureRollback(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'PartialFailureDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create HNSW index on embeddings + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'embeddings_index', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'], + ]); + + sleep(2); + + // Create an existing document + $duplicateId = ID::unique(); + $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' => $duplicateId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['email' => 'existing@example.com'] + ] + ]); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add operations - mix of valid and invalid (duplicate id) + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => ['email' => 'valid1@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => ['email' => 'valid2@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => $duplicateId, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.4), + 'metadata' => ['email' => 'existing@example.com'] + ] + ], + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.5), + 'metadata' => ['email' => 'valid3@example.com'] + ] + ], + ] + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Try to commit - should fail and rollback all operations + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(409, $response['headers']['status-code']); // Conflict due to duplicate + + // Verify NO new documents were created (atomicity) + $documents = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(1, $documents['body']['total']); // Only the original document + $this->assertEquals('existing@example.com', $documents['body']['documents'][0]['metadata']['email']); + } + + /** + * Test double commit/rollback attempts + */ + public function testDoubleCommitRollback(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DoubleCommitDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [Permission::create(Role::any())], + ]); + + $collectionId = $collection['body']['$id']; + + // Test double commit + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Add operation + $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'create', + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['data' => 'Test'] + ] + ] + ] + ]); + + // First commit + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Second commit attempt - should fail + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already committed + + // Test double rollback + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId2 = $transaction2['body']['$id']; + + // First rollback + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Second rollback attempt - should fail + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId2}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rollback' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); // Bad request - already rolled back + } + + /** + * Test operations on non-existent documents + */ + public function testOperationsOnNonExistentDocuments(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'NonExistentDocDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Try to update non-existent document - should fail at staging time with early validation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'update', + 'documentId' => 'non_existent_doc', + 'data' => [ + 'metadata' => ['data' => 'Should fail'] + ] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Document not found at staging time + + // Test delete non-existent document - should also fail at staging time with early validation + $transaction2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId2 = $transaction2['body']['$id']; + + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId2}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'action' => 'delete', + 'documentId' => 'non_existent_doc', + 'data' => [] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); // Document not found at staging time + } + + /** + * Test createDocument with transactionId via normal route + */ + public function testCreateDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'WriteRoutesTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'documentSecurity' => false, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Create document via normal route with transactionId + $response = $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_from_route', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Created via normal route', + 'counter' => 100, + 'category' => 'test' + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Document should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_from_route", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_from_route", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Created via normal route', $response['body']['metadata']['name']); + } + + /** + * Test updateDocument with transactionId via normal route + */ + public function testUpdateDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'UpdateRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document outside transaction + $doc = $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_to_update', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Original name', + 'counter' => 50, + 'category' => 'original' + ] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Update document via normal route with transactionId + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'metadata' => [ + 'name' => 'Updated via normal route', + 'counter' => 150, + 'category' => 'updated' + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should still have original values outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('Original name', $response['body']['metadata']['name']); + $this->assertEquals(50, $response['body']['metadata']['counter']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now have updated values + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_update", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals('Updated via normal route', $response['body']['metadata']['name']); + $this->assertEquals(150, $response['body']['metadata']['counter']); + } + + /** + * Test upsertDocument with transactionId via normal route + */ + public function testUpsertDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'UpsertRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Upsert document (create) via normal route with transactionId + $response = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_upsert', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Created by upsert', + 'counter' => 25 + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Document should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Upsert same document (update) in same transaction + $response = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => 'doc_upsert', + 'data' => [ + 'metadata' => [ + 'name' => 'Updated by upsert', + 'counter' => 75 + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(201, $response['headers']['status-code']); // Upsert in transaction returns 201 + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should now exist with updated values + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_upsert", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('Updated by upsert', $response['body']['metadata']['name']); + $this->assertEquals(75, $response['body']['metadata']['counter']); + } + + /** + * Test deleteDocument with transactionId via normal route + */ + public function testDeleteDocument(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'DeleteRouteTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create document outside transaction + $doc = $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_to_delete', + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => ['name' => 'Will be deleted'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Delete document via normal route with transactionId + $response = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'transactionId' => $transactionId + ]); + + $this->assertEquals(204, $response['headers']['status-code']); + + // Document should still exist outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Document should no longer exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/doc_to_delete", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + } + + /** + * Test bulkCreate with transactionId via normal route + */ + public function testBulkCreate(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkCreateTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Bulk create via normal route with transactionId + $response = $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_create_1', + 'embeddings' => $this->generateEmbeddings(3), + 'metadata' => [ + 'name' => 'Bulk created 1', + 'category' => 'bulk_created' + ] + ], + [ + '$id' => 'bulk_create_2', + 'embeddings' => $this->generateEmbeddings(3, 0.2), + 'metadata' => [ + 'name' => 'Bulk created 2', + 'category' => 'bulk_created' + ] + ], + [ + '$id' => 'bulk_create_3', + 'embeddings' => $this->generateEmbeddings(3, 0.3), + 'metadata' => [ + 'name' => 'Bulk created 3', + 'category' => 'bulk_created' + ] + ] + ], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); // Bulk operations return 200 + + // Documents should not exist outside transaction yet + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', [['metadata' => ['category' => 'bulk_created']]])->toString()] + ]); + + $this->assertEquals(0, $response['body']['total']); + + // Individual document check + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/bulk_create_1", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should now exist + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_created']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + + // Verify individual documents + for ($i = 1; $i <= 3; $i++) { + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/bulk_create_{$i}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("Bulk created {$i}", $response['body']['metadata']['name']); + $this->assertEquals('bulk_created', $response['body']['metadata']['category']); + } + } + + /** + * Test bulkUpdate with transactionId via normal route + */ + public function testBulkUpdate(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkUpdateTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create documents for bulk testing + for ($i = 1; $i <= 3; $i++) { + $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' => 'bulk_update_' . $i, + 'data' => [ + 'embeddings' => $this->generateEmbeddings(3, 0.1 * $i), + 'metadata' => [ + 'name' => 'Bulk doc ' . $i, + 'category' => 'bulk_test' + ] + ] + ]); + } + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $transactionId = $transaction['body']['$id']; + + // Bulk update via normal route with transactionId + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_test']])->toString()], + 'data' => ['metadata' => ['category' => 'bulk_updated']], + 'transactionId' => $transactionId + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should still have original category outside transaction + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_test']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + + // Commit transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Documents should now have updated category + $response = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::equal('metadata', ['metadata' => ['category' => 'bulk_updated']])->toString()] + ]); + + $this->assertEquals(3, $response['body']['total']); + } + + /** + * Test bulkUpsert with transactionId via normal route + */ + public function testBulkUpsert(): void + { + // Create database and collection + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'BulkUpsertTestDB' + ]); + + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'TestCollection', + 'dimension' => 3, + 'permissions' => [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + ]); + + $collectionId = $collection['body']['$id']; + + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Invalid action type + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'invalidAction', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 2: Missing required action field + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 3: Missing required databaseId field + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'collectionId' => $collectionId, + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 4: Missing documentId for create operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 5: Missing data for create operation + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'documentId' => ID::unique() + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 6: BulkCreate with non-array data + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'bulkCreate', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => 'not an array' + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 7: BulkUpdate with missing queries + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'bulkUpdate', + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + 'data' => [ + 'data' => ['name' => 'Updated'] + ] + ] + ] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 8: Empty operations array + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [] + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 9: Operations not an array + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => 'not an array' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test validation for committing/rolling back transactions + */ + public function testCommitRollbackValidation(): void + { + // Create transaction + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Missing both commit and rollback + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), []); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 2: Both commit and rollback set to true + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true, + 'rollback' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + // Test 3: Invalid transaction ID + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/invalid_id", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Commit the transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Test 4: Attempt to commit already committed transaction + $response = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/transactions/{$transactionId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'commit' => true + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + } + + /** + * Test validation for non-existent resources + */ + public function testNonExistentResources(): void + { + // Create database and transaction + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'ResourceTestDatabase' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $transaction = $this->client->call(Client::METHOD_POST, '/vectorsdb/transactions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(201, $transaction['headers']['status-code']); + $transactionId = $transaction['body']['$id']; + + // Test 1: Non-existent database + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => 'nonExistentDatabase', + 'collectionId' => 'someCollection', + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + // Test 2: Non-existent collection + $response = $this->client->call(Client::METHOD_POST, "/vectorsdb/transactions/{$transactionId}/operations", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'operations' => [ + [ + 'action' => 'create', + 'databaseId' => $databaseId, + 'collectionId' => 'nonExistentCollection', + 'documentId' => ID::unique(), + 'data' => ['name' => 'Test'] + ] + ] + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php new file mode 100644 index 0000000000..40ff27c572 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDB/Transactions/TransactionsConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'databaseId' => ID::unique(), + 'name' => 'Vector Console DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Vector Console DB', $database['body']['name']); + $this->assertTrue($database['body']['enabled']); + + $databaseId = $database['body']['$id']; + + /** + * Test for SUCCESS + */ + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $movies['headers']['status-code']); + $this->assertEquals($movies['body']['name'], 'Movies'); + + /** + * Test when database is disabled but can still create collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Vector Console DB Updated', + 'enabled' => false, + ]); + + $this->assertFalse($database['body']['enabled']); + + $tvShows = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'collectionId' => ID::unique(), + 'name' => 'TvShows', + 'dimension' => 3, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + /** + * Test when collection is disabled but can still modify collections + */ + $database = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $movies['body']['$id'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies', + 'enabled' => false, + ]); + + $this->assertEquals(201, $tvShows['headers']['status-code']); + $this->assertEquals($tvShows['body']['name'], 'TvShows'); + + return ['moviesId' => $movies['body']['$id'], 'databaseId' => $databaseId, 'tvShowsId' => $tvShows['body']['$id']]; + } + + #[Depends('testCreateCollection')] + public function testListCollection(array $data) + { + /** + * Test when database is disabled but can still call list collections + */ + $databaseId = $data['databaseId']; + + $collections = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders())); + + $this->assertEquals(200, $collections['headers']['status-code']); + $this->assertEquals(2, $collections['body']['total']); + } + + #[Depends('testCreateCollection')] + public function testGetCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test when database and collection are disabled but can still call get collection + */ + $collection = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testUpdateCollection(array $data) + { + $databaseId = $data['databaseId']; + $moviesCollectionId = $data['moviesId']; + + /** + * Test When database and collection are disabled but can still call update collection + */ + $collection = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $moviesCollectionId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'name' => 'Movies Updated', + 'enabled' => false + ]); + + $this->assertEquals(200, $collection['headers']['status-code']); + $this->assertEquals('Movies Updated', $collection['body']['name']); + $this->assertEquals($moviesCollectionId, $collection['body']['$id']); + $this->assertFalse($collection['body']['enabled']); + } + + #[Depends('testCreateCollection')] + public function testDeleteCollection(array $data) + { + $databaseId = $data['databaseId']; + $tvShowsId = $data['tvShowsId']; + + /** + * Test when database and collection are disabled but can still call delete collection + */ + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $tvShowsId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $response['headers']['status-code']); + $this->assertEquals($response['body'], ""); + } + + #[Depends('testCreateCollection')] + public function testGetDatabaseUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(11, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsNumeric($response['body']['collectionsTotal']); + $this->assertIsArray($response['body']['collections']); + $this->assertIsArray($response['body']['documents']); + } + + + #[Depends('testCreateCollection')] + public function testGetCollectionUsage(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for FAILURE + */ + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '32h' + ]); + + $this->assertEquals(400, $response['headers']['status-code']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/randomCollectionId/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + + $this->assertEquals(404, $response['headers']['status-code']); + + /** + * Test for SUCCESS + */ + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/usage', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'range' => '24h' + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals(3, count($response['body'])); + $this->assertEquals('24h', $response['body']['range']); + $this->assertIsNumeric($response['body']['documentsTotal']); + $this->assertIsArray($response['body']['documents']); + } + + #[Depends('testCreateCollection')] + public function testGetCollectionLogs(array $data) + { + $databaseId = $data['databaseId']; + /** + * Test for SUCCESS + */ + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertIsNumeric($logs['body']['total']); + + $logs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $data['moviesId'] . '/logs', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [Query::offset(1)->toString(), Query::limit(1)->toString()] + ]); + + $this->assertEquals(200, $logs['headers']['status-code']); + $this->assertIsArray($logs['body']['logs']); + $this->assertLessThanOrEqual(1, count($logs['body']['logs'])); + $this->assertIsNumeric($logs['body']['total']); + } +} diff --git a/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php new file mode 100644 index 0000000000..7add5c7f71 --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDBCustomClientTest.php @@ -0,0 +1,205 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $databaseId = $database['body']['$id']; + + // Collection aliases write to create, update, delete + $movies = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Movies', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ], + ]); + + $moviesId = $movies['body']['$id']; + + $this->assertContains(Permission::create(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $movies['body']['$permissions']); + + // VectorsDB uses fixed schema (embeddings, metadata). No attribute creation needed. + + // Document aliases write to update, delete + $document1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::write(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertNotContains(Permission::create(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::update(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + $this->assertContains(Permission::delete(Role::user($this->getUser()['$id'])), $document1['body']['$permissions']); + + /** + * Test for FAILURE + */ + + // Document does not allow create permission + $document2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $moviesId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['k' => 'v'], + ], + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ] + ]); + + $this->assertEquals(400, $document2['headers']['status-code']); + } + + public function testUpdateWithoutPermission(): array + { + // As a part of preparation, we get ID of currently logged-in user + $response = $this->client->call(Client::METHOD_GET, '/account', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + + $userId = $response['body']['$id']; + + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::custom('permissionCheckDatabase'), + 'name' => 'Test Database', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $this->assertEquals('Test Database', $database['body']['name']); + + $databaseId = $database['body']['$id']; + // Create collection + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::custom('permissionCheck'), + 'name' => 'permissionCheck', + 'dimension' => 3, + 'permissions' => [], + 'documentSecurity' => true, + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + // Creating document by server, give read permission to our user + some other user + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documentId' => ID::custom('permissionCheckDocument'), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'AppwriteBeginner'], + ], + 'permissions' => [ + Permission::read(Role::user(ID::custom('user2'))), + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + + // Update document + // This is the point of this test. We should be allowed to do this action, and it should not fail on permission check + $response = $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'AppwriteExpert'], + ] + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Get name of the document, should be the new one + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck/documents/permissionCheckDocument', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals("AppwriteExpert", $response['body']['metadata']['name']); + + // Cleanup to prevent collision with other tests + // Delete collection + $response = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(204, $response['headers']['status-code']); + + + // Wait for database worker to finish deleting collection + sleep(2); + + // Make sure collection has been deleted + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/permissionCheck', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + $this->assertEquals(404, $response['headers']['status-code']); + + return []; + } +} diff --git a/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php b/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php new file mode 100644 index 0000000000..ceb672443e --- /dev/null +++ b/tests/e2e/Services/Databases/VectorsDBCustomServerTest.php @@ -0,0 +1,963 @@ +client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('first'), + 'name' => 'Test 1', + ]); + $this->assertEquals(201, $db1['headers']['status-code']); + $this->assertEquals('Test 1', $db1['body']['name']); + $this->assertEquals('vectorsdb', $db1['body']['type']); + // Validate database response model fields on create + $this->assertArrayHasKey('$id', $db1['body']); + $this->assertArrayHasKey('$createdAt', $db1['body']); + $this->assertArrayHasKey('$updatedAt', $db1['body']); + $this->assertArrayHasKey('enabled', $db1['body']); + + $db2 = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::custom('second'), + 'name' => 'Test 2', + ]); + $this->assertEquals(201, $db2['headers']['status-code']); + $this->assertEquals('Test 2', $db2['body']['name']); + $this->assertEquals('vectorsdb', $db2['body']['type']); + + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['databases']); + $this->assertArrayHasKey('$id', $list['body']['databases'][0]); + $this->assertArrayHasKey('name', $list['body']['databases'][0]); + $this->assertArrayHasKey('type', $list['body']['databases'][0]); + + return ['databaseId' => $db1['body']['$id']]; + } + + #[Depends('testListDatabases')] + public function testGetDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals($databaseId, $res['body']['$id']); + $this->assertEquals('Test 1', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testUpdateDatabase(array $data): array + { + $databaseId = $data['databaseId']; + $res = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $res['body']['name']); + $this->assertEquals('vectorsdb', $res['body']['type']); + return ['databaseId' => $databaseId]; + } + + #[Depends('testListDatabases')] + public function testDeleteDatabase(array $data): void + { + $databaseId = $data['databaseId']; + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + + public function testCollectionsCRUD(): array + { + // Create database for collections tests + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Collections DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create two collections + $col1 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1', + 'collectionId' => ID::custom('first'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col1['headers']['status-code']); + // Validate collection response model on create + $this->assertArrayHasKey('$id', $col1['body']); + $this->assertArrayHasKey('$createdAt', $col1['body']); + $this->assertArrayHasKey('$updatedAt', $col1['body']); + $this->assertArrayHasKey('enabled', $col1['body']); + $this->assertArrayHasKey('documentSecurity', $col1['body']); + $this->assertArrayHasKey('dimension', $col1['body']); + + $col2 = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 2', + 'collectionId' => ID::custom('second'), + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + $this->assertEquals(201, $col2['headers']['status-code']); + $this->assertArrayHasKey('$id', $col2['body']); + $this->assertArrayHasKey('$createdAt', $col2['body']); + $this->assertArrayHasKey('$updatedAt', $col2['body']); + + // List collections + $list = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsInt($list['body']['total']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + $this->assertIsArray($list['body']['collections']); + $this->assertArrayHasKey('$id', $list['body']['collections'][0]); + $this->assertArrayHasKey('name', $list['body']['collections'][0]); + $this->assertArrayHasKey('dimension', $list['body']['collections'][0]); + + // Get collection + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($col1['body']['$id'], $get['body']['$id']); + $this->assertEquals('Test 1', $get['body']['name']); + $this->assertEquals(3, $get['body']['dimension']); + + // Update collection (name only) + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $col1['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Updated', + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Updated', $upd['body']['name']); + $this->assertArrayHasKey('$updatedAt', $upd['body']); + + // Delete collection + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $col2['body']['$id'], [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + $this->assertEquals("", $del['body']); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $col1['body']['$id'], + ]; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionMore(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Update collection name and dimensions + $upd = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test 1 Renamed', + 'dimension' => 4, + ]); + $this->assertEquals(200, $upd['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $upd['body']['name']); + $this->assertEquals(4, $upd['body']['dimension']); + + // Read back to confirm + $get = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('Test 1 Renamed', $get['body']['name']); + $this->assertEquals(4, $get['body']['dimension']); + + return $data; + } + + #[Depends('testCollectionsCRUD')] + public function testUpdateCollectionEnabledFlag(array $data): array + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Disable collection + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + // Re-enable collection + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Updated', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + return $data; + } + + public function testUpdateDatabaseNameAndEnabled(): void + { + // Create isolated database for this test to avoid ordering conflicts + $create = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Update DB', + ]); + $this->assertEquals(201, $create['headers']['status-code']); + $databaseId = $create['body']['$id']; + + // Update name + $rename = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + ]); + $this->assertEquals(200, $rename['headers']['status-code']); + $this->assertEquals('Test DB Renamed', $rename['body']['name']); + + // Toggle enabled off then on + $disable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => false, + ]); + $this->assertEquals(200, $disable['headers']['status-code']); + $this->assertFalse($disable['body']['enabled']); + + $enable = $this->client->call(Client::METHOD_PUT, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'name' => 'Test DB Renamed', + 'enabled' => true, + ]); + $this->assertEquals(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $del = $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testRecreateIndex(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create a new index variant + $create = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean_v2', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $create['headers']['status-code']); + + // Ensure it exists + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean_v2', $get['body']['key']); + + // Delete it + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean_v2", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + } + + #[Depends('testCollectionsCRUD')] + public function testIndexesCRUD(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + // Create indexes + $eu = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_euclidean', + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $eu['headers']['status-code']); + + $dot = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_dot', + 'type' => Database::INDEX_HNSW_DOT, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $dot['headers']['status-code']); + + $cos = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'embedding_cosine', + 'type' => Database::INDEX_HNSW_COSINE, + 'attributes' => ['embeddings'] + ]); + $this->assertEquals(202, $cos['headers']['status-code']); + + // List indexes + $list = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $list['headers']['status-code']); + $this->assertIsArray($list['body']['indexes']); + $keys = array_map(fn ($i) => $i['key'], $list['body']['indexes']); + $this->assertContains('embedding_euclidean', $keys); + $this->assertContains('embedding_dot', $keys); + $this->assertContains('embedding_cosine', $keys); + + // Get index by key + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_euclidean", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals('embedding_euclidean', $get['body']['key']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $get['body']['type']); + + // Delete index + $del = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(204, $del['headers']['status-code']); + sleep(4); + // Ensure it's gone + $getMissing = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/indexes/embedding_dot", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $getMissing['headers']['status-code']); + } + + public function testBulkCreate(): array + { + // Setup: create isolated database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'BulkDBCreate' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColCreate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['group' => 'bulkA'], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['group' => 'bulkB'], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(201, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + $this->assertNotEmpty($ids[0]); + $this->assertNotEmpty($ids[1]); + + // Fetch and validate persisted data via GET + foreach ($ids as $i => $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertEquals($id, $get['body']['$id']); + $this->assertIsArray($get['body']['embeddings']); + $this->assertCount(3, $get['body']['embeddings']); + $this->assertArrayHasKey('group', $get['body']['metadata']); + } + + return [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'bulkIds' => $ids ]; + } + + public function testCreateTextEmbeddingsSuccessAndErrors(): void + { + // Setup new database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'EmbedDB', + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'EmbedCol', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Success: two embeddings + $this->assertEventually(function () { + $ok = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'hello world', + 'second sentence', + ], + ]); + $this->assertEquals(200, $ok['headers']['status-code']); + $this->assertIsInt($ok['body']['total'] ?? 0); + $this->assertEquals(2, $ok['body']['total']); + $this->assertIsArray($ok['body']['embeddings']); + $this->assertCount(2, $ok['body']['embeddings']); + foreach ($ok['body']['embeddings'] as $embed) { + $this->assertIsString($embed['model']); + $this->assertIsInt($embed['dimension']); + $this->assertIsArray($embed['embedding']); + $this->assertGreaterThan(0, count($embed['embedding'])); + $this->assertArrayHasKey('error', $embed); + } + }, 3000, 100); + + // Error: missing texts payload + $missingTexts = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], []); + $this->assertEquals(400, $missingTexts['headers']['status-code']); + + // Error: invalid texts item type (must be strings) + $invalidItem = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'embeddinggemma', + 'texts' => [ + 'valid text', + 123, // invalid, not a string + ], + ]); + $this->assertEquals(400, $invalidItem['headers']['status-code']); + + // Error: unknown embedding model + $unknownModel = $this->client->call(Client::METHOD_POST, "/vectorsdb/embeddings/text", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'model' => 'nonexistent-model', + 'texts' => ['hello'], + ]); + $this->assertEquals(400, $unknownModel['headers']['status-code']); + } + + public function testBulkUpsert(): void + { + // Setup fresh db/collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpsert' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpsert', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $docs = [ + [ + 'embeddings' => [0.5, 0.5, 0.0], + 'metadata' => ['group' => 'bulkA', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + [ + 'embeddings' => [0.2, 0.8, 0.0], + 'metadata' => ['group' => 'bulkB', 'updated' => true], + '$permissions' => [Permission::read(Role::any())] + ], + ]; + + $res = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + $this->assertTrue($res['body']['documents'][0]['metadata']['updated']); + $this->assertTrue($res['body']['documents'][1]['metadata']['updated']); + + // Fetch and validate updated content + $ids = array_map(fn ($d) => $d['$id'], $res['body']['documents']); + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['updated']); + } + + // Perform another bulk upsert to mutate the same documents + $docs2 = [ + [ 'embeddings' => [0.6, 0.4, 0.0], 'metadata' => ['updatedAgain' => true] ], + [ 'embeddings' => [0.3, 0.7, 0.0], 'metadata' => ['updatedAgain' => true] ], + ]; + $res2 = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => $docs2 + ]); + $this->assertEquals(200, $res2['headers']['status-code']); + $this->assertIsArray($res2['body']['documents']); + $this->assertCount(2, $res2['body']['documents']); + + // Fetch again and assert second update persisted + $ids2 = array_map(fn ($d) => $d['$id'], $res2['body']['documents']); + foreach ($ids2 as $id) { + $get2 = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get2['headers']['status-code']); + $this->assertTrue($get2['body']['metadata']['updatedAgain']); + } + } + + public function testBulkUpdate(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBUpdate' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColUpdate', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_PATCH, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ 'metadata' => ['bulkUpdated' => true] ], + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsArray($res['body']['documents']); + $this->assertCount(2, $res['body']['documents']); + foreach ($res['body']['documents'] as $doc) { + $this->assertTrue($doc['metadata']['bulkUpdated']); + } + + // Fetch by IDs and assert update persisted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['metadata']['bulkUpdated']); + } + } + + public function testBulkDelete(): void + { + // Setup: create db/collection and two docs + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ 'databaseId' => ID::unique(), 'name' => 'BulkDBDelete' ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'BulkColDelete', + 'documentSecurity' => true, + 'dimension' => 3, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + $seed = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documents' => [ + ['embeddings' => [1.0,0.0,0.0], 'metadata' => ['seed' => 1], '$permissions' => [Permission::read(Role::any())]], + ['embeddings' => [0.0,1.0,0.0], 'metadata' => ['seed' => 2], '$permissions' => [Permission::read(Role::any())]] + ] + ]); + $this->assertEquals(200, $seed['headers']['status-code']); + $ids = array_map(fn ($d) => $d['$id'], $seed['body']['documents']); + + $res = $this->client->call(Client::METHOD_DELETE, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'queries' => [ + \Utopia\Database\Query::equal('$id', $ids)->toString() + ] + ]); + $this->assertEquals(200, $res['headers']['status-code']); + $this->assertIsInt($res['body']['total'] ?? 0); + $this->assertGreaterThanOrEqual(2, $res['body']['total']); + + // Ensure they are deleted + foreach ($ids as $id) { + $get = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$id}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + $this->assertEquals(404, $get['headers']['status-code']); + } + } + + public function testCustomTimestamps(): void + { + // Setup: create database and collection + $db = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'TimestampTestDB' + ]); + $this->assertEquals(201, $db['headers']['status-code']); + $databaseId = $db['body']['$id']; + + $col = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'TimestampTestCollection', + 'documentSecurity' => true, + 'dimension' => 1536, + 'permissions' => [Permission::read(Role::any())] + ]); + $this->assertEquals(201, $col['headers']['status-code']); + $collectionId = $col['body']['$id']; + + // Test: Create document with custom timestamps using PUT (upsert) + $customCreatedAt = '1970-01-01T00:00:00.000+00:00'; + $customUpdatedAt = '1970-01-01T00:00:00.000+00:00'; + $vector = array_fill(0, 1536, 0.0); + $vector[0] = 1.0; + $documentId = ID::unique(); + + $doc = $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => $documentId, + 'data' => [ + '$createdAt' => $customCreatedAt, + '$updatedAt' => $customUpdatedAt, + 'embeddings' => $vector, + 'metadata' => ['test' => 'custom_timestamps'] + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code']); + $documentId = $doc['body']['$id']; + $this->assertNotEmpty($documentId); + + // Verify timestamps were set correctly + $this->assertEquals($customCreatedAt, $doc['body']['$createdAt'], 'CreatedAt should match custom timestamp'); + $this->assertEquals($customUpdatedAt, $doc['body']['$updatedAt'], 'UpdatedAt should match custom timestamp'); + + // Fetch document and verify timestamps persist + $fetched = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $fetched['headers']['status-code']); + $this->assertEquals($customCreatedAt, $fetched['body']['$createdAt'], 'CreatedAt should persist after fetch'); + $this->assertEquals($customUpdatedAt, $fetched['body']['$updatedAt'], 'UpdatedAt should persist after fetch'); + + // Test: Update document with new custom timestamps + $newCustomUpdatedAt = '2000-01-01T12:00:00.000+00:00'; + $vector2 = array_fill(0, 1536, 0.0); + $vector2[1] = 1.0; + + $updated = $this->client->call(Client::METHOD_PUT, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'data' => [ + '$createdAt' => $customCreatedAt, // Keep original createdAt + '$updatedAt' => $newCustomUpdatedAt, // Update updatedAt + 'embeddings' => $vector2, + 'metadata' => ['test' => 'updated_timestamps'] + ] + ]); + + $this->assertEquals(200, $updated['headers']['status-code']); + $this->assertEquals($customCreatedAt, $updated['body']['$createdAt'], 'CreatedAt should remain unchanged'); + $this->assertEquals($newCustomUpdatedAt, $updated['body']['$updatedAt'], 'UpdatedAt should be updated to new custom timestamp'); + + // Final verification + $final = $this->client->call(Client::METHOD_GET, "/vectorsdb/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $final['headers']['status-code']); + $this->assertEquals($customCreatedAt, $final['body']['$createdAt'], 'CreatedAt should persist through updates'); + $this->assertEquals($newCustomUpdatedAt, $final['body']['$updatedAt'], 'UpdatedAt should reflect the latest custom timestamp'); + } + +} diff --git a/tests/e2e/Services/Functions/FunctionsBase.php b/tests/e2e/Services/Functions/FunctionsBase.php index 77c9367c44..af426d5221 100644 --- a/tests/e2e/Services/Functions/FunctionsBase.php +++ b/tests/e2e/Services/Functions/FunctionsBase.php @@ -264,7 +264,7 @@ trait FunctionsBase $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php index 508ddede4a..d0b2190f1c 100644 --- a/tests/e2e/Services/Functions/FunctionsCustomServerTest.php +++ b/tests/e2e/Services/Functions/FunctionsCustomServerTest.php @@ -991,7 +991,7 @@ class FunctionsCustomServerTest extends Scope */ $folder = 'large'; $code = realpath(__DIR__ . '/../../../resources/functions') . "/$folder/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/$folder && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); $chunkSize = 5 * 1024 * 1024; $handle = @fopen($code, "rb"); diff --git a/tests/e2e/Services/GraphQL/Base.php b/tests/e2e/Services/GraphQL/Base.php index 3e2624f83c..c42679018e 100644 --- a/tests/e2e/Services/GraphQL/Base.php +++ b/tests/e2e/Services/GraphQL/Base.php @@ -3464,7 +3464,7 @@ trait Base $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/GraphQL/FunctionsClientTest.php b/tests/e2e/Services/GraphQL/FunctionsClientTest.php index 234d8fa71b..8dc2fe337f 100644 --- a/tests/e2e/Services/GraphQL/FunctionsClientTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsClientTest.php @@ -24,8 +24,8 @@ class FunctionsClientTest extends Scope protected function setupFunction(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFunction[$key])) { - return static::$cachedFunction[$key]; + if (!empty(self::$cachedFunction[$key])) { + return self::$cachedFunction[$key]; } $projectId = $this->getProject()['$id']; @@ -79,15 +79,15 @@ class FunctionsClientTest extends Scope $this->assertIsArray($variables['body']['data']); $this->assertArrayNotHasKey('errors', $variables['body']); - static::$cachedFunction[$key] = $function; + self::$cachedFunction[$key] = $function; return $function; } protected function setupDeployment(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedDeployment[$key])) { - return static::$cachedDeployment[$key]; + if (!empty(self::$cachedDeployment[$key])) { + return self::$cachedDeployment[$key]; } $function = $this->setupFunction(); @@ -146,15 +146,15 @@ class FunctionsClientTest extends Scope $this->assertEquals('ready', $deployment['status']); }, 60000); - static::$cachedDeployment[$key] = $deployment; + self::$cachedDeployment[$key] = $deployment; return $deployment; } protected function setupExecution(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedExecution[$key])) { - return static::$cachedExecution[$key]; + if (!empty(self::$cachedExecution[$key])) { + return self::$cachedExecution[$key]; } $function = $this->setupFunction(); @@ -177,8 +177,8 @@ class FunctionsClientTest extends Scope $this->assertIsArray($execution['body']['data']); $this->assertArrayNotHasKey('errors', $execution['body']); - static::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; - return static::$cachedExecution[$key]; + self::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; + return self::$cachedExecution[$key]; } public function testCreateFunction(): void diff --git a/tests/e2e/Services/GraphQL/FunctionsServerTest.php b/tests/e2e/Services/GraphQL/FunctionsServerTest.php index a66789d646..8e1c7ac7e7 100644 --- a/tests/e2e/Services/GraphQL/FunctionsServerTest.php +++ b/tests/e2e/Services/GraphQL/FunctionsServerTest.php @@ -25,8 +25,8 @@ class FunctionsServerTest extends Scope protected function setupFunction(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFunction[$key])) { - return static::$cachedFunction[$key]; + if (!empty(self::$cachedFunction[$key])) { + return self::$cachedFunction[$key]; } $projectId = $this->getProject()['$id']; @@ -79,15 +79,15 @@ class FunctionsServerTest extends Scope $this->assertIsArray($variables['body']['data']); $this->assertArrayNotHasKey('errors', $variables['body']); - static::$cachedFunction[$key] = $function; + self::$cachedFunction[$key] = $function; return $function; } protected function setupDeployment(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedDeployment[$key])) { - return static::$cachedDeployment[$key]; + if (!empty(self::$cachedDeployment[$key])) { + return self::$cachedDeployment[$key]; } $function = $this->setupFunction(); @@ -149,15 +149,15 @@ class FunctionsServerTest extends Scope $this->assertEquals('ready', $deployment['status']); }, 120000); - static::$cachedDeployment[$key] = $deployment; + self::$cachedDeployment[$key] = $deployment; return $deployment; } protected function setupExecution(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedExecution[$key])) { - return static::$cachedExecution[$key]; + if (!empty(self::$cachedExecution[$key])) { + return self::$cachedExecution[$key]; } $deployment = $this->setupDeployment(); @@ -179,8 +179,8 @@ class FunctionsServerTest extends Scope $this->assertIsArray($execution['body']['data']); $this->assertArrayNotHasKey('errors', $execution['body']); - static::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; - return static::$cachedExecution[$key]; + self::$cachedExecution[$key] = $execution['body']['data']['functionsCreateExecution']; + return self::$cachedExecution[$key]; } public function testCreateFunction(): void @@ -496,8 +496,8 @@ class FunctionsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedDeployment[$key] = []; - static::$cachedExecution[$key] = []; + self::$cachedDeployment[$key] = []; + self::$cachedExecution[$key] = []; } /** @@ -529,6 +529,6 @@ class FunctionsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFunction[$key] = []; + self::$cachedFunction[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php b/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php index 4d34dc6b23..a4987c4078 100644 --- a/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php +++ b/tests/e2e/Services/GraphQL/Legacy/DatabaseClientTest.php @@ -43,8 +43,8 @@ class DatabaseClientTest extends Scope protected function setupDatabase(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$database[$cacheKey])) { - return static::$database[$cacheKey]; + if (!empty(self::$database[$cacheKey])) { + return self::$database[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -71,9 +71,9 @@ class DatabaseClientTest extends Scope } $this->assertIsArray($database['body']['data']); - static::$database[$cacheKey] = $database['body']['data']['databasesCreate']; + self::$database[$cacheKey] = $database['body']['data']['databasesCreate']; - return static::$database[$cacheKey]; + return self::$database[$cacheKey]; } /** @@ -82,8 +82,8 @@ class DatabaseClientTest extends Scope protected function setupCollection(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$collection[$cacheKey])) { - return static::$collection[$cacheKey]; + if (!empty(self::$collection[$cacheKey])) { + return self::$collection[$cacheKey]; } $database = $this->setupDatabase(); @@ -121,12 +121,12 @@ class DatabaseClientTest extends Scope $this->assertIsArray($collection['body']['data']); - static::$collection[$cacheKey] = [ + self::$collection[$cacheKey] = [ 'database' => $database, 'collection' => $collection['body']['data']['databasesCreateCollection'], ]; - return static::$collection[$cacheKey]; + return self::$collection[$cacheKey]; } /** @@ -206,8 +206,8 @@ class DatabaseClientTest extends Scope protected function setupDocument(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$document[$cacheKey])) { - return static::$document[$cacheKey]; + if (!empty(self::$document[$cacheKey])) { + return self::$document[$cacheKey]; } $data = $this->setupAttributes(); @@ -256,13 +256,13 @@ class DatabaseClientTest extends Scope $this->assertArrayNotHasKey('errors', $document['body']); $this->assertIsArray($document['body']['data']); - static::$document[$cacheKey] = [ + self::$document[$cacheKey] = [ 'database' => $data['database'], 'collection' => $data['collection'], 'document' => $document['body']['data']['databasesCreateDocument'], ]; - return static::$document[$cacheKey]; + return self::$document[$cacheKey]; } /** @@ -271,8 +271,8 @@ class DatabaseClientTest extends Scope protected function setupBulkData(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$bulkData[$cacheKey])) { - return static::$bulkData[$cacheKey]; + if (!empty(self::$bulkData[$cacheKey])) { + return self::$bulkData[$cacheKey]; } $project = $this->getProject(); @@ -352,13 +352,13 @@ class DatabaseClientTest extends Scope $this->assertArrayNotHasKey('errors', $res['body']); $this->assertCount(10, $res['body']['data']['databasesCreateDocuments']['documents']); - static::$bulkData[$cacheKey] = [ + self::$bulkData[$cacheKey] = [ 'databaseId' => $databaseId, 'collectionId' => $collectionId, 'projectId' => $projectId, ]; - return static::$bulkData[$cacheKey]; + return self::$bulkData[$cacheKey]; } /** diff --git a/tests/e2e/Services/GraphQL/MessagingTest.php b/tests/e2e/Services/GraphQL/MessagingTest.php index 322c51c1f7..03e7cc00f6 100644 --- a/tests/e2e/Services/GraphQL/MessagingTest.php +++ b/tests/e2e/Services/GraphQL/MessagingTest.php @@ -26,8 +26,8 @@ class MessagingTest extends Scope protected function setupProviders(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedProviders[$key])) { - return static::$cachedProviders[$key]; + if (!empty(self::$cachedProviders[$key])) { + return self::$cachedProviders[$key]; } $providersParams = [ @@ -128,15 +128,15 @@ class MessagingTest extends Scope $this->assertEquals($providersParams[$providerKey]['name'], $response['body']['data']['messagingCreate' . $providerKey . 'Provider']['name']); } - static::$cachedProviders[$key] = $providers; + self::$cachedProviders[$key] = $providers; return $providers; } protected function setupUpdatedProviders(): array { $key = $this->getProject()['$id'] . '_updated'; - if (!empty(static::$cachedProviders[$key])) { - return static::$cachedProviders[$key]; + if (!empty(self::$cachedProviders[$key])) { + return self::$cachedProviders[$key]; } $providers = $this->setupProviders(); @@ -247,15 +247,15 @@ class MessagingTest extends Scope $this->assertEquals('Mailgun2', $response['body']['data']['messagingUpdateMailgunProvider']['name']); $this->assertEquals(false, $response['body']['data']['messagingUpdateMailgunProvider']['enabled']); - static::$cachedProviders[$key] = $providers; + self::$cachedProviders[$key] = $providers; return $providers; } protected function setupTopic(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTopic[$key])) { - return static::$cachedTopic[$key]; + if (!empty(self::$cachedTopic[$key])) { + return self::$cachedTopic[$key]; } $query = $this->getQuery(self::CREATE_TOPIC); @@ -275,15 +275,15 @@ class MessagingTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('topic1', $response['body']['data']['messagingCreateTopic']['name']); - static::$cachedTopic[$key] = $response['body']['data']['messagingCreateTopic']; - return static::$cachedTopic[$key]; + self::$cachedTopic[$key] = $response['body']['data']['messagingCreateTopic']; + return self::$cachedTopic[$key]; } protected function setupUpdatedTopic(): string { $key = $this->getProject()['$id'] . '_updated'; - if (!empty(static::$cachedTopic[$key])) { - return static::$cachedTopic[$key]; + if (!empty(self::$cachedTopic[$key])) { + return self::$cachedTopic[$key]; } $topic = $this->setupTopic(); @@ -306,15 +306,15 @@ class MessagingTest extends Scope $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('topic2', $response['body']['data']['messagingUpdateTopic']['name']); - static::$cachedTopic[$key] = $topicId; + self::$cachedTopic[$key] = $topicId; return $topicId; } protected function setupSubscriber(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedSubscriber[$key])) { - return static::$cachedSubscriber[$key]; + if (!empty(self::$cachedSubscriber[$key])) { + return self::$cachedSubscriber[$key]; } $topic = $this->setupTopic(); @@ -386,15 +386,15 @@ class MessagingTest extends Scope $this->assertEquals($response['body']['data']['messagingCreateSubscriber']['targetId'], $targetId); $this->assertEquals($response['body']['data']['messagingCreateSubscriber']['target']['userId'], $userId); - static::$cachedSubscriber[$key] = $response['body']['data']['messagingCreateSubscriber']; - return static::$cachedSubscriber[$key]; + self::$cachedSubscriber[$key] = $response['body']['data']['messagingCreateSubscriber']; + return self::$cachedSubscriber[$key]; } protected function setupEmail(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedEmail[$key])) { - return static::$cachedEmail[$key]; + if (!empty(self::$cachedEmail[$key])) { + return self::$cachedEmail[$key]; } if (empty(System::getEnv('_APP_MESSAGE_EMAIL_TEST_DSN'))) { @@ -550,15 +550,15 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedEmail[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedEmail[$key]; + self::$cachedEmail[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedEmail[$key]; } protected function setupSms(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedSms[$key])) { - return static::$cachedSms[$key]; + if (!empty(self::$cachedSms[$key])) { + return self::$cachedSms[$key]; } if (empty(System::getEnv('_APP_MESSAGE_SMS_TEST_DSN'))) { @@ -709,15 +709,15 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedSms[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedSms[$key]; + self::$cachedSms[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedSms[$key]; } protected function setupPush(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedPush[$key])) { - return static::$cachedPush[$key]; + if (!empty(self::$cachedPush[$key])) { + return self::$cachedPush[$key]; } if (empty(System::getEnv('_APP_MESSAGE_PUSH_TEST_DSN'))) { @@ -870,8 +870,8 @@ class MessagingTest extends Scope $this->assertEquals(1, $message['body']['data']['messagingGetMessage']['deliveredTotal']); $this->assertEquals(0, \count($message['body']['data']['messagingGetMessage']['deliveryErrors'])); - static::$cachedPush[$key] = $message['body']['data']['messagingGetMessage']; - return static::$cachedPush[$key]; + self::$cachedPush[$key] = $message['body']['data']['messagingGetMessage']; + return self::$cachedPush[$key]; } public function testCreateProviders(): void @@ -945,8 +945,8 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedProviders[$key] = []; - static::$cachedProviders[$key . '_updated'] = []; + self::$cachedProviders[$key] = []; + self::$cachedProviders[$key . '_updated'] = []; } public function testCreateTopic(): void @@ -1081,7 +1081,7 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedSubscriber[$key] = []; + self::$cachedSubscriber[$key] = []; } public function testDeleteTopic() @@ -1105,8 +1105,8 @@ class MessagingTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedTopic[$key] = []; - static::$cachedTopic[$key . '_updated'] = []; + self::$cachedTopic[$key] = []; + self::$cachedTopic[$key . '_updated'] = []; } public function testSendEmail(): void diff --git a/tests/e2e/Services/GraphQL/StorageClientTest.php b/tests/e2e/Services/GraphQL/StorageClientTest.php index 84af910a50..3e02de0585 100644 --- a/tests/e2e/Services/GraphQL/StorageClientTest.php +++ b/tests/e2e/Services/GraphQL/StorageClientTest.php @@ -23,8 +23,8 @@ class StorageClientTest extends Scope protected function setupBucket(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedBucket[$key])) { - return static::$cachedBucket[$key]; + if (!empty(self::$cachedBucket[$key])) { + return self::$cachedBucket[$key]; } $projectId = $this->getProject()['$id']; @@ -55,15 +55,15 @@ class StorageClientTest extends Scope $bucket = $bucket['body']['data']['storageCreateBucket']; $this->assertEquals('Actors', $bucket['name']); - static::$cachedBucket[$key] = $bucket; + self::$cachedBucket[$key] = $bucket; return $bucket; } protected function setupFile(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFile[$key])) { - return static::$cachedFile[$key]; + if (!empty(self::$cachedFile[$key])) { + return self::$cachedFile[$key]; } $bucket = $this->setupBucket(); @@ -99,8 +99,8 @@ class StorageClientTest extends Scope $this->assertIsArray($file['body']['data']); $this->assertArrayNotHasKey('errors', $file['body']); - static::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; - return static::$cachedFile[$key]; + self::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; + return self::$cachedFile[$key]; } public function testCreateBucket(): void @@ -319,6 +319,6 @@ class StorageClientTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFile[$key] = []; + self::$cachedFile[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/StorageServerTest.php b/tests/e2e/Services/GraphQL/StorageServerTest.php index 8a3158a98b..9622582e80 100644 --- a/tests/e2e/Services/GraphQL/StorageServerTest.php +++ b/tests/e2e/Services/GraphQL/StorageServerTest.php @@ -23,8 +23,8 @@ class StorageServerTest extends Scope protected function setupBucket(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedBucket[$key])) { - return static::$cachedBucket[$key]; + if (!empty(self::$cachedBucket[$key])) { + return self::$cachedBucket[$key]; } $projectId = $this->getProject()['$id']; @@ -54,15 +54,15 @@ class StorageServerTest extends Scope $bucket = $bucket['body']['data']['storageCreateBucket']; $this->assertEquals('Actors', $bucket['name']); - static::$cachedBucket[$key] = $bucket; + self::$cachedBucket[$key] = $bucket; return $bucket; } protected function setupFile(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedFile[$key])) { - return static::$cachedFile[$key]; + if (!empty(self::$cachedFile[$key])) { + return self::$cachedFile[$key]; } $bucket = $this->setupBucket(); @@ -98,8 +98,8 @@ class StorageServerTest extends Scope $this->assertIsArray($file['body']['data']); $this->assertArrayNotHasKey('errors', $file['body']); - static::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; - return static::$cachedFile[$key]; + self::$cachedFile[$key] = $file['body']['data']['storageCreateFile']; + return self::$cachedFile[$key]; } public function testCreateBucket(): void @@ -413,7 +413,7 @@ class StorageServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedFile[$key] = []; + self::$cachedFile[$key] = []; } /** @@ -443,7 +443,7 @@ class StorageServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedBucket[$key] = []; - static::$cachedFile[$key] = []; + self::$cachedBucket[$key] = []; + self::$cachedFile[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php b/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php index 3078202546..f0b4e4b75c 100644 --- a/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php +++ b/tests/e2e/Services/GraphQL/TablesDB/DatabaseServerTest.php @@ -39,8 +39,8 @@ class DatabaseServerTest extends Scope protected function setupDatabase(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedDatabase[$cacheKey])) { - return static::$cachedDatabase[$cacheKey]; + if (!empty(self::$cachedDatabase[$cacheKey])) { + return self::$cachedDatabase[$cacheKey]; } $projectId = $this->getProject()['$id']; @@ -62,15 +62,15 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $database['body']); - static::$cachedDatabase[$cacheKey] = $database['body']['data']['tablesDBCreate']; - return static::$cachedDatabase[$cacheKey]; + self::$cachedDatabase[$cacheKey] = $database['body']['data']['tablesDBCreate']; + return self::$cachedDatabase[$cacheKey]; } protected function setupTable(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedTableData[$cacheKey])) { - return static::$cachedTableData[$cacheKey]; + if (!empty(self::$cachedTableData[$cacheKey])) { + return self::$cachedTableData[$cacheKey]; } $database = $this->setupDatabase(); @@ -124,20 +124,20 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $table2['body']); $table2 = $table2['body']['data']['tablesDBCreateTable']; - static::$cachedTableData[$cacheKey] = [ + self::$cachedTableData[$cacheKey] = [ 'database' => $database, 'table' => $table, 'table2' => $table2, ]; - return static::$cachedTableData[$cacheKey]; + return self::$cachedTableData[$cacheKey]; } protected function setupStringColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedStringColumnData[$cacheKey])) { - return static::$cachedStringColumnData[$cacheKey]; + if (!empty(self::$cachedStringColumnData[$cacheKey])) { + return self::$cachedStringColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -159,8 +159,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedStringColumnData[$cacheKey] = $data; - return static::$cachedStringColumnData[$cacheKey]; + self::$cachedStringColumnData[$cacheKey] = $data; + return self::$cachedStringColumnData[$cacheKey]; } protected function setupUpdatedStringColumn(): array @@ -205,8 +205,8 @@ class DatabaseServerTest extends Scope protected function setupIntegerColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIntegerColumnData[$cacheKey])) { - return static::$cachedIntegerColumnData[$cacheKey]; + if (!empty(self::$cachedIntegerColumnData[$cacheKey])) { + return self::$cachedIntegerColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -229,8 +229,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedIntegerColumnData[$cacheKey] = $data; - return static::$cachedIntegerColumnData[$cacheKey]; + self::$cachedIntegerColumnData[$cacheKey] = $data; + return self::$cachedIntegerColumnData[$cacheKey]; } protected function setupUpdatedIntegerColumn(): array @@ -275,8 +275,8 @@ class DatabaseServerTest extends Scope protected function setupBooleanColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedBooleanColumnData[$cacheKey])) { - return static::$cachedBooleanColumnData[$cacheKey]; + if (!empty(self::$cachedBooleanColumnData[$cacheKey])) { + return self::$cachedBooleanColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -297,8 +297,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedBooleanColumnData[$cacheKey] = $data; - return static::$cachedBooleanColumnData[$cacheKey]; + self::$cachedBooleanColumnData[$cacheKey] = $data; + return self::$cachedBooleanColumnData[$cacheKey]; } protected function setupUpdatedBooleanColumn(): array @@ -341,8 +341,8 @@ class DatabaseServerTest extends Scope protected function setupFloatColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedFloatColumnData[$cacheKey])) { - return static::$cachedFloatColumnData[$cacheKey]; + if (!empty(self::$cachedFloatColumnData[$cacheKey])) { + return self::$cachedFloatColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -366,8 +366,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedFloatColumnData[$cacheKey] = $data; - return static::$cachedFloatColumnData[$cacheKey]; + self::$cachedFloatColumnData[$cacheKey] = $data; + return self::$cachedFloatColumnData[$cacheKey]; } protected function setupUpdatedFloatColumn(): array @@ -412,8 +412,8 @@ class DatabaseServerTest extends Scope protected function setupEmailColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedEmailColumnData[$cacheKey])) { - return static::$cachedEmailColumnData[$cacheKey]; + if (!empty(self::$cachedEmailColumnData[$cacheKey])) { + return self::$cachedEmailColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -434,8 +434,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedEmailColumnData[$cacheKey] = $data; - return static::$cachedEmailColumnData[$cacheKey]; + self::$cachedEmailColumnData[$cacheKey] = $data; + return self::$cachedEmailColumnData[$cacheKey]; } protected function setupUpdatedEmailColumn(): array @@ -478,8 +478,8 @@ class DatabaseServerTest extends Scope protected function setupEnumColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedEnumColumnData[$cacheKey])) { - return static::$cachedEnumColumnData[$cacheKey]; + if (!empty(self::$cachedEnumColumnData[$cacheKey])) { + return self::$cachedEnumColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -505,8 +505,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedEnumColumnData[$cacheKey] = $data; - return static::$cachedEnumColumnData[$cacheKey]; + self::$cachedEnumColumnData[$cacheKey] = $data; + return self::$cachedEnumColumnData[$cacheKey]; } protected function setupUpdatedEnumColumn(): array @@ -554,8 +554,8 @@ class DatabaseServerTest extends Scope protected function setupDatetimeColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedDatetimeColumnData[$cacheKey])) { - return static::$cachedDatetimeColumnData[$cacheKey]; + if (!empty(self::$cachedDatetimeColumnData[$cacheKey])) { + return self::$cachedDatetimeColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -576,8 +576,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedDatetimeColumnData[$cacheKey] = $data; - return static::$cachedDatetimeColumnData[$cacheKey]; + self::$cachedDatetimeColumnData[$cacheKey] = $data; + return self::$cachedDatetimeColumnData[$cacheKey]; } protected function setupUpdatedDatetimeColumn(): array @@ -620,8 +620,8 @@ class DatabaseServerTest extends Scope protected function setupRelationshipColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedRelationshipColumnData[$cacheKey])) { - return static::$cachedRelationshipColumnData[$cacheKey]; + if (!empty(self::$cachedRelationshipColumnData[$cacheKey])) { + return self::$cachedRelationshipColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -645,8 +645,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedRelationshipColumnData[$cacheKey] = $data; - return static::$cachedRelationshipColumnData[$cacheKey]; + self::$cachedRelationshipColumnData[$cacheKey] = $data; + return self::$cachedRelationshipColumnData[$cacheKey]; } protected function setupUpdatedRelationshipColumn(): array @@ -688,8 +688,8 @@ class DatabaseServerTest extends Scope protected function setupIPColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIPColumnData[$cacheKey])) { - return static::$cachedIPColumnData[$cacheKey]; + if (!empty(self::$cachedIPColumnData[$cacheKey])) { + return self::$cachedIPColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -711,8 +711,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedIPColumnData[$cacheKey] = $data; - return static::$cachedIPColumnData[$cacheKey]; + self::$cachedIPColumnData[$cacheKey] = $data; + return self::$cachedIPColumnData[$cacheKey]; } protected function setupUpdatedIPColumn(): array @@ -755,8 +755,8 @@ class DatabaseServerTest extends Scope protected function setupURLColumn(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedURLColumnData[$cacheKey])) { - return static::$cachedURLColumnData[$cacheKey]; + if (!empty(self::$cachedURLColumnData[$cacheKey])) { + return self::$cachedURLColumnData[$cacheKey]; } $data = $this->setupTable(); @@ -778,8 +778,8 @@ class DatabaseServerTest extends Scope 'x-appwrite-project' => $projectId, ], $this->getHeaders()), $gqlPayload); - static::$cachedURLColumnData[$cacheKey] = $data; - return static::$cachedURLColumnData[$cacheKey]; + self::$cachedURLColumnData[$cacheKey] = $data; + return self::$cachedURLColumnData[$cacheKey]; } protected function setupUpdatedURLColumn(): array @@ -822,8 +822,8 @@ class DatabaseServerTest extends Scope protected function setupIndex(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedIndexData[$cacheKey])) { - return static::$cachedIndexData[$cacheKey]; + if (!empty(self::$cachedIndexData[$cacheKey])) { + return self::$cachedIndexData[$cacheKey]; } // Need updated string and integer columns first @@ -855,31 +855,31 @@ class DatabaseServerTest extends Scope if (isset($index['body']['errors'])) { $errorMessage = $index['body']['errors'][0]['message'] ?? ''; if (strpos($errorMessage, 'already exists') !== false || strpos($errorMessage, 'Document with the requested ID already exists') !== false) { - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => ['key' => 'index'], ]; - return static::$cachedIndexData[$cacheKey]; + return self::$cachedIndexData[$cacheKey]; } } $this->assertArrayNotHasKey('errors', $index['body']); - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => $index['body']['data']['tablesDBCreateIndex'], ]; - return static::$cachedIndexData[$cacheKey]; + return self::$cachedIndexData[$cacheKey]; } protected function setupRow(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedRowData[$cacheKey])) { - return static::$cachedRowData[$cacheKey]; + if (!empty(self::$cachedRowData[$cacheKey])) { + return self::$cachedRowData[$cacheKey]; } // Need all columns that the row data references @@ -940,20 +940,20 @@ class DatabaseServerTest extends Scope $this->assertArrayNotHasKey('errors', $row['body']); $row = $row['body']['data']['tablesDBCreateRow']; - static::$cachedRowData[$cacheKey] = [ + self::$cachedRowData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'row' => $row, ]; - return static::$cachedRowData[$cacheKey]; + return self::$cachedRowData[$cacheKey]; } protected function setupBulkData(): array { $cacheKey = $this->getProject()['$id'] ?? 'default'; - if (!empty(static::$cachedBulkData[$cacheKey])) { - return static::$cachedBulkData[$cacheKey]; + if (!empty(self::$cachedBulkData[$cacheKey])) { + return self::$cachedBulkData[$cacheKey]; } $project = $this->getProject(); @@ -1034,9 +1034,9 @@ class DatabaseServerTest extends Scope $this->client->call(Client::METHOD_POST, '/graphql', $headers, $payload); - static::$cachedBulkData[$cacheKey] = compact('databaseId', 'tableId', 'projectId'); + self::$cachedBulkData[$cacheKey] = compact('databaseId', 'tableId', 'projectId'); - return static::$cachedBulkData[$cacheKey]; + return self::$cachedBulkData[$cacheKey]; } public function testCreateDatabase(): void @@ -1088,7 +1088,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedStringColumnData[$cacheKey] = $data; + self::$cachedStringColumnData[$cacheKey] = $data; } /** @@ -1166,7 +1166,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIntegerColumnData[$cacheKey] = $data; + self::$cachedIntegerColumnData[$cacheKey] = $data; } /** @@ -1246,7 +1246,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedBooleanColumnData[$cacheKey] = $data; + self::$cachedBooleanColumnData[$cacheKey] = $data; } /** @@ -1325,7 +1325,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedFloatColumnData[$cacheKey] = $data; + self::$cachedFloatColumnData[$cacheKey] = $data; } /** @@ -1405,7 +1405,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedEmailColumnData[$cacheKey] = $data; + self::$cachedEmailColumnData[$cacheKey] = $data; } /** @@ -1486,7 +1486,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedEnumColumnData[$cacheKey] = $data; + self::$cachedEnumColumnData[$cacheKey] = $data; } @@ -1570,7 +1570,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedDatetimeColumnData[$cacheKey] = $data; + self::$cachedDatetimeColumnData[$cacheKey] = $data; } /** @@ -1646,7 +1646,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedRelationshipColumnData[$cacheKey] = $data; + self::$cachedRelationshipColumnData[$cacheKey] = $data; } public function testUpdateRelationshipColumn(): void @@ -1717,7 +1717,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIPColumnData[$cacheKey] = $data; + self::$cachedIPColumnData[$cacheKey] = $data; } /** @@ -1794,7 +1794,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedURLColumnData[$cacheKey] = $data; + self::$cachedURLColumnData[$cacheKey] = $data; } /** @@ -1877,7 +1877,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedIndexData[$cacheKey] = [ + self::$cachedIndexData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'index' => $index['body']['data']['tablesDBCreateIndex'], @@ -1952,7 +1952,7 @@ class DatabaseServerTest extends Scope // Store for caching $cacheKey = $this->getProject()['$id'] ?? 'default'; - static::$cachedRowData[$cacheKey] = [ + self::$cachedRowData[$cacheKey] = [ 'database' => $data['database'], 'table' => $data['table'], 'row' => $row, diff --git a/tests/e2e/Services/GraphQL/TeamsClientTest.php b/tests/e2e/Services/GraphQL/TeamsClientTest.php index 44cf3c9d12..e6c27f44f8 100644 --- a/tests/e2e/Services/GraphQL/TeamsClientTest.php +++ b/tests/e2e/Services/GraphQL/TeamsClientTest.php @@ -20,8 +20,8 @@ class TeamsClientTest extends Scope protected function setupTeam(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeam[$key])) { - return static::$cachedTeam[$key]; + if (!empty(self::$cachedTeam[$key])) { + return self::$cachedTeam[$key]; } $projectId = $this->getProject()['$id']; @@ -45,15 +45,15 @@ class TeamsClientTest extends Scope $team = $team['body']['data']['teamsCreate']; $this->assertEquals('Team Name', $team['name']); - static::$cachedTeam[$key] = $team; + self::$cachedTeam[$key] = $team; return $team; } protected function setupMembership(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedMembership[$key])) { - return static::$cachedMembership[$key]; + if (!empty(self::$cachedMembership[$key])) { + return self::$cachedMembership[$key]; } $team = $this->setupTeam(); @@ -82,7 +82,7 @@ class TeamsClientTest extends Scope $this->assertEquals($team['_id'], $membership['teamId']); $this->assertEquals(['developer'], $membership['roles']); - static::$cachedMembership[$key] = $membership; + self::$cachedMembership[$key] = $membership; return $membership; } @@ -211,6 +211,6 @@ class TeamsClientTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedMembership[$key] = []; + self::$cachedMembership[$key] = []; } } diff --git a/tests/e2e/Services/GraphQL/TeamsServerTest.php b/tests/e2e/Services/GraphQL/TeamsServerTest.php index bd0939040c..ff6e8e3c6f 100644 --- a/tests/e2e/Services/GraphQL/TeamsServerTest.php +++ b/tests/e2e/Services/GraphQL/TeamsServerTest.php @@ -22,8 +22,8 @@ class TeamsServerTest extends Scope protected function setupTeam(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeam[$key])) { - return static::$cachedTeam[$key]; + if (!empty(self::$cachedTeam[$key])) { + return self::$cachedTeam[$key]; } $projectId = $this->getProject()['$id']; @@ -47,15 +47,15 @@ class TeamsServerTest extends Scope $team = $team['body']['data']['teamsCreate']; $this->assertEquals('Team Name', $team['name']); - static::$cachedTeam[$key] = $team; + self::$cachedTeam[$key] = $team; return $team; } protected function setupMembership(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedMembership[$key])) { - return static::$cachedMembership[$key]; + if (!empty(self::$cachedMembership[$key])) { + return self::$cachedMembership[$key]; } $team = $this->setupTeam(); @@ -83,15 +83,15 @@ class TeamsServerTest extends Scope $this->assertEquals($team['_id'], $membership['teamId']); $this->assertEquals(['developer'], $membership['roles']); - static::$cachedMembership[$key] = $membership; + self::$cachedMembership[$key] = $membership; return $membership; } protected function setupTeamWithPrefs(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedTeamWithPrefs[$key])) { - return static::$cachedTeamWithPrefs[$key]; + if (!empty(self::$cachedTeamWithPrefs[$key])) { + return self::$cachedTeamWithPrefs[$key]; } $team = $this->setupTeam(); @@ -137,7 +137,7 @@ class TeamsServerTest extends Scope $this->assertIsArray($prefs['body']['data']['teamsUpdatePrefs']); $this->assertEquals('{"key":"value"}', $prefs['body']['data']['teamsUpdatePrefs']['data']); - static::$cachedTeamWithPrefs[$key] = $fetchedTeam; + self::$cachedTeamWithPrefs[$key] = $fetchedTeam; return $fetchedTeam; } @@ -349,7 +349,7 @@ class TeamsServerTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedMembership[$key] = []; + self::$cachedMembership[$key] = []; } #[Group('cl-ignore')] diff --git a/tests/e2e/Services/GraphQL/UsersTest.php b/tests/e2e/Services/GraphQL/UsersTest.php index da9f761567..efe99531be 100644 --- a/tests/e2e/Services/GraphQL/UsersTest.php +++ b/tests/e2e/Services/GraphQL/UsersTest.php @@ -21,8 +21,8 @@ class UsersTest extends Scope protected function setupUser(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedUser[$key])) { - return static::$cachedUser[$key]; + if (!empty(self::$cachedUser[$key])) { + return self::$cachedUser[$key]; } $projectId = $this->getProject()['$id']; @@ -50,15 +50,15 @@ class UsersTest extends Scope $this->assertEquals('Project User', $user['name']); $this->assertEquals($email, $user['email']); - static::$cachedUser[$key] = $user; + self::$cachedUser[$key] = $user; return $user; } protected function setupUserTarget(): array { $key = $this->getProject()['$id']; - if (!empty(static::$cachedUserTarget[$key])) { - return static::$cachedUserTarget[$key]; + if (!empty(self::$cachedUserTarget[$key])) { + return self::$cachedUserTarget[$key]; } $user = $this->setupUser(); @@ -106,8 +106,8 @@ class UsersTest extends Scope $this->assertEquals(200, $target['headers']['status-code']); $this->assertEquals('random-email@mail.org', $target['body']['data']['usersCreateTarget']['identifier']); - static::$cachedUserTarget[$key] = $target['body']['data']['usersCreateTarget']; - return static::$cachedUserTarget[$key]; + self::$cachedUserTarget[$key] = $target['body']['data']['usersCreateTarget']; + return self::$cachedUserTarget[$key]; } public function testCreateUser(): void @@ -581,7 +581,7 @@ class UsersTest extends Scope // Clear cache after deletion $key = $this->getProject()['$id']; - static::$cachedUserTarget[$key] = []; + self::$cachedUserTarget[$key] = []; } public function testDeleteUser() diff --git a/tests/e2e/Services/Migrations/MigrationsBase.php b/tests/e2e/Services/Migrations/MigrationsBase.php index 0d992c472e..6b446145fe 100644 --- a/tests/e2e/Services/Migrations/MigrationsBase.php +++ b/tests/e2e/Services/Migrations/MigrationsBase.php @@ -2,12 +2,15 @@ namespace Tests\E2E\Services\Migrations; +use Appwrite\Tests\Retry; use CURLFile; +use PHPUnit\Framework\Attributes\Depends; use Tests\E2E\Client; use Tests\E2E\General\UsageTest; use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Services\Functions\FunctionsBase; use Utopia\Console; +use Utopia\Database\Database; use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -186,6 +189,26 @@ trait MigrationsBase return $migrationResult; } + /** + * Get migration status by ID (without creating a new migration) + * + * @param string $migrationId + * @return array + */ + public function getMigrationStatus(string $migrationId): array + { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + + return $response['body']; + } + /** * Appwrite E2E Migration Tests */ @@ -1165,7 +1188,7 @@ trait MigrationsBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); } @@ -2532,4 +2555,1984 @@ trait MigrationsBase 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], ]); } + + /** + * Import VectorsDB documents from CSV + */ + public function testImportVectordbCSV(): void + { + $databaseId = null; + $collectionId = null; + $bucketId = null; + + try { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Vector CSV Import DB' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Vector CSV Import Collection', + 'dimension' => 3, + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Vector CSV Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['csv'], + ]); + + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/csv/vectorsdb-documents.csv'), 'text/csv', 'vectorsdb-documents.csv'), + ]); + + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + $migration = $this->performCsvMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + + $this->assertEventually(function () use ($migration) { + $migrationId = $migration['body']['$id']; + $status = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $status['headers']['status-code']); + $this->assertEquals('finished', $status['body']['stage']); + $this->assertEquals('completed', $status['body']['status']); + $this->assertContains(Resource::TYPE_DOCUMENT, $status['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $status['body']['statusCounters']); + $this->assertEquals(2, $status['body']['statusCounters'][Resource::TYPE_DOCUMENT]['success']); + + return true; + }, 60_000, 500); + + $documents = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'queries' => [ + Query::limit(10)->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertEquals(2, $documents['body']['total']); + + $titles = array_map(fn ($doc) => $doc['metadata']['title'] ?? null, $documents['body']['documents']); + $this->assertContains('Vector Alpha', $titles); + $this->assertContains('Vector Beta', $titles); + } finally { + if ($bucketId) { + $this->client->call(Client::METHOD_DELETE, '/storage/buckets/' . $bucketId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + if ($databaseId) { + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + } + } + + /** + * Export VectorsDB documents to CSV + */ + #[Retry(count: 1)] + public function testExportVectordbCSV(): void + { + $databaseId = null; + + try { + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Vector CSV Export DB', + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collectionId = null; + $this->assertEventually(function () use ($databaseId, &$collectionId) { + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Vector CSV Export Collection', + 'dimension' => 3, + 'documentSecurity' => true, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + }); + + $documentsPayload = [ + [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.11, 0.22, 0.33], + 'metadata' => ['title' => 'Vector Sample One', 'category' => 'alpha'], + ], + ], + [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.44, 0.55, 0.66], + 'metadata' => ['title' => 'Vector Sample Two', 'category' => 'beta'], + ], + ], + ]; + + foreach ($documentsPayload as $payload) { + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], $payload); + + $this->assertEquals(201, $response['headers']['status-code']); + } + + $filename = 'vectorsdb-export-' . ID::unique(); + $migration = $this->client->call(Client::METHOD_POST, '/migrations/csv/exports', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => $filename, + 'columns' => [], + 'queries' => [], + 'delimiter' => ',', + 'enclosure' => '"', + 'escape' => '\\', + 'header' => true, + 'notify' => true, + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + + $migrationId = $migration['body']['$id']; + $this->assertEventually(function () use ($migrationId) { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('finished', $response['body']['stage']); + $this->assertEquals('completed', $response['body']['status']); + + return true; + }, 30_000, 500); + + $this->assertEventually(function () { + $email = $this->getLastEmail(1, function (array $email) { + $this->assertEquals('Your CSV export is ready', $email['subject']); + }); + $this->assertNotEmpty($email); + $this->assertEquals('Your CSV export is ready', $email['subject']); + \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $email['html'], $matches); + $this->assertNotEmpty($matches[1], 'Download URL not found in email'); + $downloadUrl = html_entity_decode($matches[1]); + $components = \parse_url($downloadUrl); + $this->assertNotEmpty($components); + \parse_str($components['query'] ?? '', $queryParams); + $this->assertArrayHasKey('jwt', $queryParams); + $this->assertArrayHasKey('project', $queryParams); + + $path = \str_replace('/v1', '', $components['path']); + $downloadResponse = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); + $this->assertEquals(200, $downloadResponse['headers']['status-code']); + + $csvData = $downloadResponse['body']; + $this->assertStringContainsString('Vector Sample One', $csvData); + $this->assertStringContainsString('Vector Sample Two', $csvData); + $this->assertStringContainsString('[0.11,0.22,0.33]', $csvData); + }, 30_000, 500); + } finally { + if ($databaseId) { + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + } + } + + /** + * DocumentsDB (schemaless) + */ + public function testAppwriteMigrationDocumentsDBDatabase(): array + { + $response = $this->client->call(Client::METHOD_POST, '/documentsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'DocsDB - Migration DB' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + + $databaseId = $response['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_DATABASE_DOCUMENTSDB], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($databaseId, $response['body']['$id']); + $this->assertEquals('DocsDB - Migration DB', $response['body']['name']); + + // Cleanup on destination + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + ]; + } + + /** + * VectorsDB (embeddings collections) + */ + public function testAppwriteMigrationVectorsDBDatabase(): array + { + $response = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'VDB - Migration DB' + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + + $databaseId = $response['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + $this->assertEquals([Resource::TYPE_DATABASE_VECTORSDB], $result['resources']); + $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error'] ?? 0); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($databaseId, $response['body']['$id']); + $this->assertEquals('VDB - Migration DB', $response['body']['name']); + + // Cleanup on destination + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + ]; + } + + #[Depends('testAppwriteMigrationVectorsDBDatabase')] + public function testAppwriteMigrationVectorsDBCollection(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'VDB - Movies', + 'dimension' => 3, + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $collectionId = $collection['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_ATTRIBUTE, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $result['status']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($collectionId, $response['body']['$id']); + $this->assertEquals('VDB - Movies', $response['body']['name']); + // Verify attributes are present (embeddings and metadata are default attributes) + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + ]; + } + + #[Depends('testAppwriteMigrationVectorsDBCollection')] + public function testAppwriteMigrationVectorsDBDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['title' => 'Migration Test Movie'], + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + // Ensure attributes are exported before documents + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_DOCUMENT, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + // Verify that TYPE_ATTRIBUTE appears in the resources array for VectorsDB + $this->assertContains(Resource::TYPE_ATTRIBUTE, $result['resources'], 'TYPE_ATTRIBUTE should be in resources array for VectorsDB'); + + // Verify attributes exist on destination before checking document + $collectionResponse = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $collectionResponse['headers']['status-code']); + $this->assertArrayHasKey('attributes', $collectionResponse['body']); + $this->assertIsArray($collectionResponse['body']['attributes']); + + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($documentId, $response['body']['$id']); + $this->assertEquals('Migration Test Movie', $response['body']['metadata']['title']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + #[Depends('testAppwriteMigrationDocumentsDBDatabase')] + public function testAppwriteMigrationDocumentsDBCollection(array $data): array + { + $databaseId = $data['databaseId']; + + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'DocsDB - Movies', + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + + $collectionId = $collection['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, // collections in DocumentsDB map to tables in migration + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + $this->assertEquals('completed', $result['status']); + foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB, Resource::TYPE_COLLECTION] as $resource) { + $this->assertArrayHasKey($resource, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][$resource]['error']); + $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); + $this->assertEquals(1, $result['statusCounters'][$resource]['success']); + $this->assertEquals(0, $result['statusCounters'][$resource]['processing']); + $this->assertEquals(0, $result['statusCounters'][$resource]['warning']); + } + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($collectionId, $response['body']['$id']); + $this->assertEquals('DocsDB - Movies', $response['body']['name']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + return [ + 'databaseId' => $databaseId, + 'collectionId' => $collectionId, + ]; + } + + #[Depends('testAppwriteMigrationDocumentsDBCollection')] + public function testAppwriteMigrationDocumentsDBDocument(array $data): void + { + $databaseId = $data['databaseId']; + $collectionId = $data['collectionId']; + + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'title' => 'Migration Test Movie', + 'releaseYear' => 1999, + ] + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + $result = $this->performMigrationSync([ + 'resources' => [ + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $this->getProject()['$id'], + 'apiKey' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals('completed', $result['status']); + + foreach ([Resource::TYPE_DATABASE_DOCUMENTSDB] as $resource) { + $this->assertArrayHasKey($resource, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][$resource]['error']); + $this->assertEquals(0, $result['statusCounters'][$resource]['pending']); + $this->assertEquals(1, $result['statusCounters'][$resource]['success']); + } + + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']); + $this->assertEquals($documentId, $response['body']['$id']); + $this->assertEquals('Migration Test Movie', $response['body']['title']); + $this->assertEquals(1999, $response['body']['releaseYear']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + } + + /** + * Migrate a project that contains both SQL Databases (/databases) and + * schemaless DocumentsDB (/documentsdb) in a single run and verify results. + * Uses a dedicated isolated source project to avoid interference from other tests. + */ + public function testAppwriteMigrationMixedDatabases(): void + { + // Create a fresh isolated source project for this test + $sourceProject = $this->getProject(true); + + // ====== Create SQL Database (/databases) with table, column, and row ====== + $sql = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed SQL DB', + ]); + + $this->assertEquals(201, $sql['headers']['status-code']); + $this->assertNotEmpty($sql['body']['$id']); + $sqlDatabaseId = $sql['body']['$id']; + + // Create Table in SQL Database + $table = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'tableId' => ID::unique(), + 'name' => 'Products', + ]); + + $this->assertEquals(201, $table['headers']['status-code']); + $tableId = $table['body']['$id']; + + // Create Column in Table + $column = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => 'productName', + 'size' => 255, + 'required' => true, + ]); + + $this->assertEquals(202, $column['headers']['status-code']); + + // Wait for column to be ready + $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sourceProject) { + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('available', $response['body']['status']); + }, 5000, 500); + + $sqlIndexKey = 'product_unique'; + + $sqlIndex = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $sqlIndexKey, + 'type' => Database::INDEX_UNIQUE, + 'columns' => ['productName'], + ]); + + $this->assertEquals(202, $sqlIndex['headers']['status-code']); + + $this->assertEventually(function () use ($sqlDatabaseId, $tableId, $sqlIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('available', $index['body']['status']); + }, 30000, 500); + + // Create Row in Table + $row = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'rowId' => ID::unique(), + 'data' => [ + 'productName' => 'Laptop', + ], + ]); + + $this->assertEquals(201, $row['headers']['status-code']); + $rowId = $row['body']['$id']; + + // ====== Create DocumentsDB (/documentsdb) with collection and document ====== + $docs = $this->client->call(Client::METHOD_POST, '/documentsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed DocsDB', + ]); + + $this->assertEquals(201, $docs['headers']['status-code']); + $this->assertNotEmpty($docs['body']['$id']); + $docsDatabaseId = $docs['body']['$id']; + + // Create Collection in DocumentsDB + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Users', + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + $documentsIndexKey = 'email_unique'; + + $documentsIndex = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $documentsIndexKey, + 'type' => Database::INDEX_UNIQUE, + 'attributes' => ['email'], + ]); + + $this->assertEquals(202, $documentsIndex['headers']['status-code']); + + $this->assertEventually(function () use ($docsDatabaseId, $collectionId, $documentsIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals('available', $index['body']['status']); + }, 30000, 500); + + // Create Document in Collection + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + ]); + + $this->assertEquals(201, $document['headers']['status-code']); + $documentId = $document['body']['$id']; + + // ====== Create VectorsDB (/vectorsdb) with collection and document ====== + $vector = $this->client->call(Client::METHOD_POST, '/vectorsdb', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Mixed VectorsDB', + ]); + + $this->assertEquals(201, $vector['headers']['status-code']); + $this->assertNotEmpty($vector['body']['$id']); + $vectorDatabaseId = $vector['body']['$id']; + + // Create Collection in VectorsDB + $vectorCollection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Products', + 'dimension' => 3, + ]); + + $this->assertEquals(201, $vectorCollection['headers']['status-code']); + $vectorCollectionId = $vectorCollection['body']['$id']; + + // Wait for VectorsDB collection attributes to be ready + $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $sourceProject) { + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + // Check that default attributes (embeddings and metadata) are present and ready + $attributeKeys = array_column($response['body']['attributes'], 'key'); + $this->assertContains('embeddings', $attributeKeys); + $this->assertContains('metadata', $attributeKeys); + // Check that attributes are available (if status field exists) + foreach ($response['body']['attributes'] as $attribute) { + if (isset($attribute['status']) && $attribute['status'] !== 'available') { + return false; + } + } + return true; + }, 10000, 500); + + $metadataIndexKey = '_key_metadata'; + $vectorIndexes = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + $this->assertEquals(200, $vectorIndexes['headers']['status-code']); + $metadataIndex = null; + foreach ($vectorIndexes['body']['indexes'] ?? [] as $index) { + if (($index['key'] ?? '') === $metadataIndexKey) { + $metadataIndex = $index; + break; + } + } + $this->assertNotNull($metadataIndex, 'Default metadata index should exist on source collection'); + $this->assertEquals(Database::INDEX_OBJECT, $metadataIndex['type']); + + $vectorEmbeddingIndexKey = 'embedding_euclidean'; + $vectorEmbeddingIndex = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'key' => $vectorEmbeddingIndexKey, + 'type' => Database::INDEX_HNSW_EUCLIDEAN, + 'attributes' => ['embeddings'], + ]); + $this->assertEquals(202, $vectorEmbeddingIndex['headers']['status-code']); + + $this->assertEventually(function () use ($vectorDatabaseId, $vectorCollectionId, $vectorEmbeddingIndexKey, $sourceProject) { + $index = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes/' . $vectorEmbeddingIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->assertEquals(200, $index['headers']['status-code']); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $index['body']['type']); + if (isset($index['body']['status'])) { + $this->assertEquals('available', $index['body']['status']); + } + }, 30000, 500); + + // Create Document in VectorsDB Collection + $vectorDocument = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [0.5, 0.3, 0.2], + 'metadata' => ['name' => 'Product Vector'], + ], + ]); + + $this->assertEquals(201, $vectorDocument['headers']['status-code']); + $vectorDocumentId = $vectorDocument['body']['$id']; + + // ====== Perform migration including all three database kinds with all child resources ====== + $migrationConfig = [ + 'resources' => [ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_INDEX, + ], + 'endpoint' => $this->endpoint, + 'projectId' => $sourceProject['$id'], + 'apiKey' => $sourceProject['apiKey'], + ]; + + // Perform migration sync once and get migration ID + $result = $this->performMigrationSync($migrationConfig); + $migrationId = $result['$id']; + $this->assertEquals('completed', $result['status']); + $this->assertEquals('Appwrite', $result['source']); + $this->assertEquals('Appwrite', $result['destination']); + $this->assertEquals([ + Resource::TYPE_DATABASE, + Resource::TYPE_TABLE, + Resource::TYPE_COLUMN, + Resource::TYPE_ROW, + Resource::TYPE_DATABASE_DOCUMENTSDB, + Resource::TYPE_COLLECTION, + Resource::TYPE_DOCUMENT, + Resource::TYPE_DATABASE_VECTORSDB, + Resource::TYPE_ATTRIBUTE, + Resource::TYPE_INDEX, + ], $result['resources']); + + // Get migration status before asserting SQL Database counters + $result = $this->getMigrationStatus($migrationId); + // Assert SQL Database counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE]['warning']); + + // Get migration status before asserting Table counters + $result = $this->getMigrationStatus($migrationId); + // Assert Table counters + $this->assertArrayHasKey(Resource::TYPE_TABLE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_TABLE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_TABLE]['warning']); + + // Get migration status before asserting Column counters + $result = $this->getMigrationStatus($migrationId); + // Assert Column counters + $this->assertArrayHasKey(Resource::TYPE_COLUMN, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_COLUMN]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLUMN]['warning']); + + // Get migration status before asserting Row counters + $result = $this->getMigrationStatus($migrationId); + // Assert Row counters + $this->assertArrayHasKey(Resource::TYPE_ROW, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_ROW]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ROW]['warning']); + + // Get migration status before asserting DocumentsDB counters + $result = $this->getMigrationStatus($migrationId); + // Assert DocumentsDB counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE_DOCUMENTSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_DOCUMENTSDB]['warning']); + + // Wait for all collections to be fully processed and status counters to be updated + // Note: Collections are being transferred but status counters may not be updated immediately + // This wait ensures the migration worker has finished processing all collections + $result = null; + $this->assertEventually(function () use ($migrationId, &$result) { + $result = $this->getMigrationStatus($migrationId); + + // Check if collections status counters exist + if (!isset($result['statusCounters'][Resource::TYPE_COLLECTION])) { + return false; + } + + $pendingCount = $result['statusCounters'][Resource::TYPE_COLLECTION]['pending'] ?? 0; + + // Return true only when pending count is 0 + return $pendingCount === 0; + }, 30000, 1000); // 30 second timeout, check every 1 second + + // Assert Collection counters (covers both DocumentsDB and VectorsDB collections) + $this->assertArrayHasKey(Resource::TYPE_COLLECTION, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_COLLECTION]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_COLLECTION]['warning']); + + // Get migration status before asserting Document counters + $result = $this->getMigrationStatus($migrationId); + // Assert Document counters (covers both DocumentsDB and VectorsDB documents) + $this->assertArrayHasKey(Resource::TYPE_DOCUMENT, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_DOCUMENT]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DOCUMENT]['warning']); + + // Get migration status before asserting VectorsDB counters + $result = $this->getMigrationStatus($migrationId); + // Assert VectorsDB counters + $this->assertArrayHasKey(Resource::TYPE_DATABASE_VECTORSDB, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['pending']); + $this->assertEquals(1, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_DATABASE_VECTORSDB]['warning']); + + // Get migration status before asserting Attribute counters + $result = $this->getMigrationStatus($migrationId); + // Assert Attribute counters (for VectorsDB) + $this->assertArrayHasKey(Resource::TYPE_ATTRIBUTE, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['pending']); + $this->assertGreaterThanOrEqual(1, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_ATTRIBUTE]['warning']); + + // Get migration status before asserting Index counters + $result = $this->getMigrationStatus($migrationId); + $this->assertArrayHasKey(Resource::TYPE_INDEX, $result['statusCounters']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['error']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['pending']); + $this->assertGreaterThanOrEqual(4, $result['statusCounters'][Resource::TYPE_INDEX]['success']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['processing']); + $this->assertEquals(0, $result['statusCounters'][Resource::TYPE_INDEX]['warning']); + + // Get migration status before asserting counter count + $result = $this->getMigrationStatus($migrationId); + // Ensure only expected counters exist (10 total) + $this->assertCount(10, $result['statusCounters']); + + // ====== Validate on destination: SQL Database resources ====== + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $sqlDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($sqlDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed SQL DB', $response['body']['name']); + + // Validate Table + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($tableId, $response['body']['$id']); + $this->assertEquals('Products', $response['body']['name']); + + // Validate Column + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/columns/productName', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('productName', $response['body']['key']); + $this->assertEquals(255, $response['body']['size']); + $this->assertEquals(true, $response['body']['required']); + + // Validate Row + $response = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/rows/' . $rowId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($rowId, $response['body']['$id']); + $this->assertEquals('Laptop', $response['body']['productName']); + + $sqlIndexDestination = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $sqlDatabaseId . '/tables/' . $tableId . '/indexes/' . $sqlIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $sqlIndexDestination['headers']['status-code']); + $this->assertEquals($sqlIndexKey, $sqlIndexDestination['body']['key']); + $this->assertEquals(Database::INDEX_UNIQUE, $sqlIndexDestination['body']['type']); + if (isset($sqlIndexDestination['body']['columns'])) { + $this->assertEquals(['productName'], $sqlIndexDestination['body']['columns']); + } + + // ====== Validate on destination: DocumentsDB resources ====== + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($docsDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed DocsDB', $response['body']['name']); + + // Validate Collection + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($collectionId, $response['body']['$id']); + $this->assertEquals('Users', $response['body']['name']); + + // Validate Document + $response = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/documents/' . $documentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($documentId, $response['body']['$id']); + $this->assertEquals('John Doe', $response['body']['name']); + $this->assertEquals('john@example.com', $response['body']['email']); + + $documentsIndexDestination = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $docsDatabaseId . '/collections/' . $collectionId . '/indexes/' . $documentsIndexKey, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $documentsIndexDestination['headers']['status-code']); + $this->assertEquals($documentsIndexKey, $documentsIndexDestination['body']['key']); + $this->assertEquals(Database::INDEX_UNIQUE, $documentsIndexDestination['body']['type']); + if (isset($documentsIndexDestination['body']['attributes'])) { + $this->assertEquals(['email'], $documentsIndexDestination['body']['attributes']); + } + + // ====== Validate on destination: VectorsDB resources ====== + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($vectorDatabaseId, $response['body']['$id']); + $this->assertEquals('Mixed VectorsDB', $response['body']['name']); + + // Validate VectorsDB Collection + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($vectorCollectionId, $response['body']['$id']); + $this->assertEquals('Products', $response['body']['name']); + // Verify attributes are present (embeddings and metadata are default attributes) + $this->assertArrayHasKey('attributes', $response['body']); + $this->assertIsArray($response['body']['attributes']); + + $vectorIndexesDestination = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/indexes', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + $this->assertEquals(200, $vectorIndexesDestination['headers']['status-code']); + $indexByKey = []; + foreach ($vectorIndexesDestination['body']['indexes'] ?? [] as $index) { + if (isset($index['key'])) { + $indexByKey[$index['key']] = $index; + } + } + $this->assertArrayHasKey($metadataIndexKey, $indexByKey, 'Metadata index should exist on destination'); + $this->assertEquals(Database::INDEX_OBJECT, $indexByKey[$metadataIndexKey]['type']); + $this->assertArrayHasKey($vectorEmbeddingIndexKey, $indexByKey, 'Embeddings HNSW index should exist on destination'); + $this->assertEquals(Database::INDEX_HNSW_EUCLIDEAN, $indexByKey[$vectorEmbeddingIndexKey]['type']); + + // Validate VectorsDB Document + $response = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $vectorDatabaseId . '/collections/' . $vectorCollectionId . '/documents/' . $vectorDocumentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($vectorDocumentId, $response['body']['$id']); + $this->assertEquals('Product Vector', $response['body']['metadata']['name']); + + // ====== Cleanup all destinations ====== + $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getDestinationProject()['$id'], + 'x-appwrite-key' => $this->getDestinationProject()['apiKey'], + ]); + + // ====== Cleanup sources ====== + $this->client->call(Client::METHOD_DELETE, '/databases/' . $sqlDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $docsDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $vectorDatabaseId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $sourceProject['$id'], + 'x-appwrite-key' => $sourceProject['apiKey'], + ]); + } + + public function testCreateJSONImport(): void + { + // Make a database + $response = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Database' + ]); + + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('Test Database', $response['body']['name']); + + $databaseId = $response['body']['$id']; + + // make a table + $response = $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'] + ]), [ + 'name' => 'Test table', + 'tableId' => ID::unique(), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals($response['body']['name'], 'Test table'); + + $tableId = $response['body']['$id']; + + // make columns + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/string', 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->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals($response['body']['key'], 'name'); + $this->assertEquals($response['body']['type'], 'string'); + $this->assertEquals($response['body']['size'], 256); + $this->assertEquals($response['body']['required'], true); + + $response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $tableId . '/columns/integer', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'key' => 'age', + 'min' => 18, + 'max' => 65, + 'required' => true, + ]); + + $this->assertEquals(202, $response['headers']['status-code']); + $this->assertEquals($response['body']['key'], 'age'); + $this->assertEquals($response['body']['type'], 'integer'); + $this->assertEquals($response['body']['min'], 18); + $this->assertEquals($response['body']['max'], 65); + $this->assertEquals($response['body']['required'], true); + + // make a bucket, upload a file to it! + $bucketOne = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ], [ + 'bucketId' => ID::unique(), + 'name' => 'Test Bucket', + 'maximumFileSize' => 2000000, //2MB + 'allowedFileExtensions' => ['json'], + 'compression' => 'gzip', + 'encryption' => true + ]); + $this->assertEquals(201, $bucketOne['headers']['status-code']); + $this->assertNotEmpty($bucketOne['body']['$id']); + + $bucketOneId = $bucketOne['body']['$id']; + + $bucketIds = [ + 'default' => $bucketOneId, + 'missing-column' => $bucketOneId, + 'irrelevant-column' => $bucketOneId, + 'documents-internals' => $bucketOneId, + ]; + + $fileIds = []; + + foreach ($bucketIds as $label => $bucketId) { + $jsonFileName = match ($label) { + 'missing-column', + 'irrelevant-column', + 'documents-internals' => "$label.json", + default => 'documents.json', + }; + + $response = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new CURLFile(realpath(__DIR__ . '/../../../resources/json/'.$jsonFileName), 'application/json', $jsonFileName), + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertNotEmpty($response['body']['$id']); + $this->assertEquals($jsonFileName, $response['body']['name']); + $this->assertEquals('application/json', $response['body']['mimeType']); + + $fileIds[$label] = $response['body']['$id']; + } + + // missing column, fail in worker. + $missingColumn = $this->performJsonMigration( + [ + 'fileId' => $fileIds['missing-column'], + 'bucketId' => $bucketIds['missing-column'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($missingColumn) { + $migrationId = $missingColumn['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('failed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + + /* fails in batch create documents unlike csv which checks headers first! */ + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertGreaterThan(0, $migration['body']['statusCounters'][Resource::TYPE_ROW]['error']); + + $this->assertThat( + implode("\n", $migration['body']['errors']), + $this->stringContains('Missing required attribute') + ); + $this->assertThat( + implode("\n", $migration['body']['errors']), + $this->stringContains('age') + ); + }, 60_000, 500); + + // irrelevant column - email, success. + $irrelevantColumn = $this->performJsonMigration( + [ + 'fileId' => $fileIds['irrelevant-column'], + 'bucketId' => $bucketIds['irrelevant-column'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($irrelevantColumn) { + $migrationId = $irrelevantColumn['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // all data exists, pass. + $migration = $this->performJsonMigration( + [ + 'endpoint' => $this->endpoint, + 'fileId' => $fileIds['default'], + 'bucketId' => $bucketIds['default'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($migration, $databaseId, $tableId) { + $migrationId = $migration['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(100, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + + // get rows count + $rows = $this->client->call(Client::METHOD_GET, '/tablesdb/'.$databaseId.'/tables/'.$tableId.'/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::limit(250)->toString() + ] + ]); + + $this->assertEquals(200, $rows['headers']['status-code']); + $this->assertIsArray($rows['body']['rows']); + $this->assertIsNumeric($rows['body']['total']); + $this->assertEquals(200, $rows['body']['total']); + + // all data exists and includes internals, pass. + $migration = $this->performJsonMigration( + [ + 'endpoint' => $this->endpoint, + 'fileId' => $fileIds['documents-internals'], + 'bucketId' => $bucketIds['documents-internals'], + 'resourceId' => $databaseId . ':' . $tableId, + ] + ); + + $this->assertEventually(function () use ($migration) { + $migrationId = $migration['body']['$id']; + $migration = $this->client->call(Client::METHOD_GET, '/migrations/'.$migrationId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('JSON', $migration['body']['source']); + $this->assertEquals('Appwrite', $migration['body']['destination']); + $this->assertContains(Resource::TYPE_ROW, $migration['body']['resources']); + $this->assertArrayHasKey(Resource::TYPE_ROW, $migration['body']['statusCounters']); + $this->assertEquals(25, $migration['body']['statusCounters'][Resource::TYPE_ROW]['success']); + }, 10_000, 500); + } + + private function performJsonMigration(array $body): array + { + return $this->client->call(Client::METHOD_POST, '/migrations/json/imports', [ + 'content-type' => 'application/json', + 'x-appwrite-key' => $this->getProject()['apiKey'], + 'x-appwrite-project' => $this->getProject()['$id'], + ], $body); + } + + /** + * Test JSON export with email notification + */ + public function testCreateJSONExport(): void + { + // Create a database + $database = $this->client->call(Client::METHOD_POST, '/databases', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'databaseId' => ID::unique(), + 'name' => 'Test Export Database' + ]); + + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create a collection + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'collectionId' => ID::unique(), + 'name' => 'Test Export Collection', + 'permissions' => [] + ]); + + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create a simple attribute like the basic test + $name = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'name', + 'size' => 255, + 'required' => true, + ]); + + $this->assertEquals(202, $name['headers']['status-code']); + + // Create a simple attribute like the basic test + $email = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'key' => 'email', + 'size' => 255, + 'required' => false, + ]); + + $this->assertEquals(202, $email['headers']['status-code']); + + \sleep(3); + + // Create sample documents + for ($i = 1; $i <= 10; $i++) { + $doc = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ], [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Test User ' . $i, + 'email' => 'user' . $i . '@appwrite.io' + ] + ]); + + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); + } + + // Verify documents were created + $docs = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Expected 10 documents but got ' . $docs['body']['total']); + + // Perform JSON export with notification enabled (uses internal bucket) + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'test-json-export', + 'columns' => [], + 'queries' => [], + 'notify' => true + ]); + + $this->assertEquals(202, $migration['headers']['status-code']); + $this->assertNotEmpty($migration['body']['$id']); + $migrationId = $migration['body']['$id']; + + $this->assertEventually(function () use ($migrationId) { + $response = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('finished', $response['body']['stage']); + $this->assertEquals('completed', $response['body']['status']); + $this->assertEquals('Appwrite', $response['body']['source']); + $this->assertEquals('JSON', $response['body']['destination']); + + return true; + }, 30_000, 500); + + // Check that email was sent with download link + $lastEmail = $this->getLastEmail(); + $this->assertNotEmpty($lastEmail); + $this->assertEquals('Your JSON export is ready', $lastEmail['subject']); + $this->assertStringContainsStringIgnoringCase('Your data export has been completed successfully', $lastEmail['text']); + + // Extract download URL from email HTML + \preg_match('/href="([^"]*\/storage\/buckets\/[^"]*\/push[^"]*)"/', $lastEmail['html'], $matches); + $this->assertNotEmpty($matches[1], 'Download URL not found in email'); + $downloadUrl = html_entity_decode($matches[1]); + + // Parse the URL to extract components + $components = \parse_url($downloadUrl); + $this->assertNotEmpty($components); + \parse_str($components['query'] ?? '', $queryParams); + $this->assertArrayHasKey('jwt', $queryParams, 'JWT not found in download URL'); + $this->assertNotEmpty($queryParams['jwt']); + $this->assertArrayHasKey('project', $queryParams, 'Project not found in download URL'); + $this->assertStringContainsString('/storage/buckets/default/files/', $downloadUrl); + + // Test download with JWT + $path = \str_replace('/v1', '', $components['path']); + $downloadWithJwt = $this->client->call(Client::METHOD_GET, $path . '?project=' . $queryParams['project'] . '&jwt=' . $queryParams['jwt']); + $this->assertEquals(200, $downloadWithJwt['headers']['status-code'], 'Failed to download file with JWT'); + + // Verify the downloaded content is valid JSON + $jsonData = $downloadWithJwt['body']; + $this->assertNotEmpty($jsonData, 'JSON export should not be empty'); + $decoded = json_decode($jsonData, true); + $this->assertIsArray($decoded, 'JSON should be valid and decodable'); + $this->assertCount(10, $decoded, 'JSON should contain 10 documents'); + $this->assertArrayHasKey('name', $decoded[0], 'JSON documents should contain name field'); + $this->assertArrayHasKey('email', $decoded[0], 'JSON documents should contain email field'); + $this->assertStringContainsString('Test User', $decoded[0]['name'], 'JSON should contain test data'); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/databases/' . $databaseId, [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]); + } + + public function testCreateVectorsDBJSONExport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create vectorsdb database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'VectorsDB Export Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection with dimension 16 + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'VecExportCol', + 'dimension' => 16, + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Seed 5 documents + for ($i = 1; $i <= 5; $i++) { + $embeddings = array_map(fn () => round((mt_rand() / mt_getrandmax()) * 2 - 1, 6), range(1, 16)); + $doc = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => $embeddings, + 'metadata' => ['title' => 'Doc ' . $i, 'score' => round($i * 0.2, 1)] + ] + ]); + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create vector document ' . $i); + } + + // Trigger JSON export + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'vectorsdb-export-test', + 'columns' => [], + 'queries' => [], + 'notify' => false, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + $migrationId = $migration['body']['$id']; + + // Poll until completed + $this->assertEventually(function () use ($migrationId, $headers) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('Appwrite', $migration['body']['source']); + $this->assertEquals('JSON', $migration['body']['destination']); + }, 30_000, 500); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); + } + + public function testCreateVectorsDBJSONImport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create vectorsdb database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'VectorsDB Import Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection with dimension 16 + $collection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'VecImportCol', + 'dimension' => 16, + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create bucket and upload test file + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'VectorsDB Import Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/vectorsdb-documents.json'), 'application/json', 'vectorsdb-documents.json'), + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + // Trigger import + $migration = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + + // Poll until completed + $this->assertEventually(function () use ($migration, $headers) { + $migrationId = $migration['body']['$id']; + $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $result['headers']['status-code']); + $this->assertEquals('finished', $result['body']['stage']); + $this->assertEquals('completed', $result['body']['status']); + $this->assertEquals('JSON', $result['body']['source']); + $this->assertEquals('Appwrite', $result['body']['destination']); + }, 30_000, 500); + + // Verify documents were imported + $docs = $this->client->call(Client::METHOD_GET, '/vectorsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 vectorsdb documents'); + + // Verify first document structure + $firstDoc = $docs['body']['documents'][0]; + $this->assertArrayHasKey('embeddings', $firstDoc); + $this->assertCount(16, $firstDoc['embeddings'], 'Imported embeddings should have 16 dimensions'); + $this->assertArrayHasKey('metadata', $firstDoc); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId, $headers); + } + + public function testCreateDocumentsDBJSONExport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create documentsdb database + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Export Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection (schemaless — no attributes needed) + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'DocExportCol', + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Seed 5 documents + for ($i = 1; $i <= 5; $i++) { + $doc = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers, [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'User ' . $i, + 'email' => 'user' . $i . '@test.com', + 'age' => 20 + $i, + 'address' => ['city' => 'City ' . $i, 'zip' => '1000' . $i] + ] + ]); + $this->assertEquals(201, $doc['headers']['status-code'], 'Failed to create document ' . $i); + } + + // Trigger JSON export + $migration = $this->client->call(Client::METHOD_POST, '/migrations/json/exports', $headers, [ + 'resourceId' => $databaseId . ':' . $collectionId, + 'filename' => 'documentsdb-export-test', + 'columns' => [], + 'queries' => [], + 'notify' => false, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + $migrationId = $migration['body']['$id']; + + // Poll until completed + $this->assertEventually(function () use ($migrationId, $headers) { + $migration = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $migration['headers']['status-code']); + $this->assertEquals('finished', $migration['body']['stage']); + $this->assertEquals('completed', $migration['body']['status']); + $this->assertEquals('Appwrite', $migration['body']['source']); + $this->assertEquals('JSON', $migration['body']['destination']); + }, 30_000, 500); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); + } + + public function testCreateDocumentsDBJSONImport(): void + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]; + + // Create documentsdb database + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', $headers, [ + 'databaseId' => ID::unique(), + 'name' => 'DocumentsDB Import Test' + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + // Create collection (schemaless) + $collection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', $headers, [ + 'collectionId' => ID::unique(), + 'name' => 'DocImportCol', + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + $collectionId = $collection['body']['$id']; + + // Create bucket and upload test file + $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', $headers, [ + 'bucketId' => ID::unique(), + 'name' => 'DocumentsDB Import Bucket', + 'maximumFileSize' => 2000000, + 'allowedFileExtensions' => ['json'], + ]); + $this->assertEquals(201, $bucket['headers']['status-code']); + $bucketId = $bucket['body']['$id']; + + $file = $this->client->call(Client::METHOD_POST, '/storage/buckets/' . $bucketId . '/files', array_merge([ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'fileId' => ID::unique(), + 'file' => new \CURLFile(realpath(__DIR__ . '/../../../resources/json/documentsdb-documents.json'), 'application/json', 'documentsdb-documents.json'), + ]); + $this->assertEquals(201, $file['headers']['status-code']); + $fileId = $file['body']['$id']; + + // Trigger import + $migration = $this->performJsonMigration([ + 'fileId' => $fileId, + 'bucketId' => $bucketId, + 'resourceId' => $databaseId . ':' . $collectionId, + ]); + $this->assertEquals(202, $migration['headers']['status-code']); + + // Poll until completed + $this->assertEventually(function () use ($migration, $headers) { + $migrationId = $migration['body']['$id']; + $result = $this->client->call(Client::METHOD_GET, '/migrations/' . $migrationId, $headers); + + $this->assertEquals(200, $result['headers']['status-code']); + $this->assertEquals('finished', $result['body']['stage']); + $this->assertEquals('completed', $result['body']['status']); + $this->assertEquals('JSON', $result['body']['source']); + $this->assertEquals('Appwrite', $result['body']['destination']); + }, 30_000, 500); + + // Verify documents were imported + $docs = $this->client->call(Client::METHOD_GET, '/documentsdb/' . $databaseId . '/collections/' . $collectionId . '/documents', $headers); + $this->assertEquals(200, $docs['headers']['status-code']); + $this->assertEquals(10, $docs['body']['total'], 'Should have imported 10 documentsdb documents'); + + // Verify first document has nested data + $firstDoc = $docs['body']['documents'][0]; + $this->assertArrayHasKey('name', $firstDoc); + $this->assertArrayHasKey('address', $firstDoc); + $this->assertIsArray($firstDoc['address']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId, $headers); + } + } diff --git a/tests/e2e/Services/Project/VariablesBase.php b/tests/e2e/Services/Project/VariablesBase.php new file mode 100644 index 0000000000..b1f8ed61b9 --- /dev/null +++ b/tests/e2e/Services/Project/VariablesBase.php @@ -0,0 +1,1102 @@ +createVariable( + ID::unique(), + 'APP_KEY', + 'my-secret-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $this->assertSame('APP_KEY', $variable['body']['key']); + $this->assertSame(true, $variable['body']['secret']); + $this->assertSame('', $variable['body']['value']); + $this->assertSame('project', $variable['body']['resourceType']); + $this->assertSame('', $variable['body']['resourceId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($variable['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($variable['body']['$updatedAt'])); + + // Verify via GET + $get = $this->getVariable($variable['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($variable['body']['$id'], $get['body']['$id']); + $this->assertSame('APP_KEY', $get['body']['key']); + + // Verify via LIST + $list = $this->listVariables(null, true); + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableNonSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'PUBLIC_KEY', + 'public-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertNotEmpty($variable['body']['$id']); + $this->assertSame('PUBLIC_KEY', $variable['body']['key']); + $this->assertSame(false, $variable['body']['secret']); + $this->assertIsBool($variable['body']['secret']); + $this->assertSame('public-value', $variable['body']['value']); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableSecretValueHidden(): void + { + $variable = $this->createVariable( + ID::unique(), + 'SECRET_KEY', + 'hidden-value', + true + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame(true, $variable['body']['secret']); + $this->assertSame('', $variable['body']['value']); + + // Verify value is also hidden on GET + $get = $this->getVariable($variable['body']['$id']); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('', $get['body']['value']); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testCreateVariableWithoutAuthentication(): void + { + $response = $this->createVariable( + ID::unique(), + 'NO_AUTH_KEY', + 'no-auth-value', + null, + false + ); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testCreateVariableInvalidId(): void + { + $variable = $this->createVariable( + '!invalid-id!', + 'INVALID_ID_KEY', + 'value', + ); + + $this->assertSame(400, $variable['headers']['status-code']); + } + + public function testCreateVariableMissingKey(): void + { + $response = $this->createVariable( + ID::unique(), + null, + 'some-value', + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateVariableMissingValue(): void + { + $response = $this->createVariable( + ID::unique(), + 'MISSING_VALUE_KEY', + null, + ); + + $this->assertSame(400, $response['headers']['status-code']); + } + + public function testCreateVariableDuplicateId(): void + { + $variableId = ID::unique(); + + $variable = $this->createVariable( + $variableId, + 'DUP_KEY_1', + 'value1', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + + // Attempt to create with same ID + $duplicate = $this->createVariable( + $variableId, + 'DUP_KEY_2', + 'value2', + ); + + $this->assertSame(409, $duplicate['headers']['status-code']); + $this->assertSame('variable_already_exists', $duplicate['body']['type']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testCreateVariableCustomId(): void + { + $customId = 'my-custom-variable-id'; + + $variable = $this->createVariable( + $customId, + 'CUSTOM_ID_KEY', + 'custom-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame($customId, $variable['body']['$id']); + + // Verify via GET + $get = $this->getVariable($customId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($customId, $get['body']['$id']); + + // Cleanup + $this->deleteVariable($customId); + } + + // Update variable tests + + public function testUpdateVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'ORIGINAL_KEY', + 'original-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update key and value + $updated = $this->updateVariable($variableId, 'UPDATED_KEY', 'updated-value'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame($variableId, $updated['body']['$id']); + $this->assertSame('UPDATED_KEY', $updated['body']['key']); + $this->assertSame('updated-value', $updated['body']['value']); + + // Verify update persisted via GET + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('UPDATED_KEY', $get['body']['key']); + $this->assertSame('updated-value', $get['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableKey(): void + { + $variable = $this->createVariable( + ID::unique(), + 'KEY_BEFORE', + 'unchanged-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only key + $updated = $this->updateVariable($variableId, 'KEY_AFTER'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('KEY_AFTER', $updated['body']['key']); + $this->assertSame('unchanged-value', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableValue(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNCHANGED_KEY', + 'value-before', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update only value + $updated = $this->updateVariable($variableId, null, 'value-after'); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame('UNCHANGED_KEY', $updated['body']['key']); + $this->assertSame('value-after', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableSetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'MAKE_SECRET_KEY', + 'some-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $this->assertSame(false, $variable['body']['secret']); + $variableId = $variable['body']['$id']; + + // Update to secret + $updated = $this->updateVariable($variableId, null, null, true); + + $this->assertSame(200, $updated['headers']['status-code']); + $this->assertSame(true, $updated['body']['secret']); + $this->assertSame('', $updated['body']['value']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableCannotUnsetSecret(): void + { + $variable = $this->createVariable( + ID::unique(), + 'UNSET_SECRET_KEY', + 'secret-value', + true + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt to unset secret + $updated = $this->updateVariable($variableId, null, null, false); + + $this->assertSame(400, $updated['headers']['status-code']); + $this->assertSame('variable_cannot_unset_secret', $updated['body']['type']); + + // Verify variable is unchanged + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame(true, $get['body']['secret']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableNoOp(): void + { + $variable = $this->createVariable( + ID::unique(), + 'NOOP_KEY', + 'noop-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Update with no parameters should fail with 400 + $updated = $this->updateVariable($variableId); + + $this->assertSame(400, $updated['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_UPDATE_KEY', + 'auth-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt update without authentication + $response = $this->updateVariable($variableId, 'UPDATED_KEY', null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testUpdateVariableNotFound(): void + { + $updated = $this->updateVariable('non-existent-id', 'NEW_KEY', 'new-value'); + + $this->assertSame(404, $updated['headers']['status-code']); + $this->assertSame('variable_not_found', $updated['body']['type']); + } + + // Get variable tests + + public function testGetVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'GET_TEST_KEY', + 'get-test-value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + $get = $this->getVariable($variableId); + + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($variableId, $get['body']['$id']); + $this->assertSame('GET_TEST_KEY', $get['body']['key']); + $this->assertSame('get-test-value', $get['body']['value']); + $this->assertSame(false, $get['body']['secret']); + $this->assertSame('project', $get['body']['resourceType']); + $this->assertSame('', $get['body']['resourceId']); + + $dateValidator = new DatetimeValidator(); + $this->assertSame(true, $dateValidator->isValid($get['body']['$createdAt'])); + $this->assertSame(true, $dateValidator->isValid($get['body']['$updatedAt'])); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testGetVariableNotFound(): void + { + $get = $this->getVariable('non-existent-id'); + + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('variable_not_found', $get['body']['type']); + } + + public function testGetVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'AUTH_GET_KEY', + 'auth-get-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt GET without authentication + $response = $this->getVariable($variableId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + // List variables tests + + public function testListVariables(): void + { + // Create multiple variables + $variable1 = $this->createVariable( + ID::unique(), + 'LIST_KEY_ALPHA', + 'alpha-value', + false + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIST_KEY_BETA', + 'beta-value', + true + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + $variable3 = $this->createVariable( + ID::unique(), + 'LIST_KEY_GAMMA', + 'gamma-value', + false + ); + $this->assertSame(201, $variable3['headers']['status-code']); + + // List all + $list = $this->listVariables(null, true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertGreaterThanOrEqual(3, $list['body']['total']); + $this->assertGreaterThanOrEqual(3, \count($list['body']['variables'])); + $this->assertIsArray($list['body']['variables']); + + // Verify structure of returned variables + foreach ($list['body']['variables'] as $variable) { + $this->assertArrayHasKey('$id', $variable); + $this->assertArrayHasKey('$createdAt', $variable); + $this->assertArrayHasKey('$updatedAt', $variable); + $this->assertArrayHasKey('key', $variable); + $this->assertArrayHasKey('value', $variable); + $this->assertArrayHasKey('secret', $variable); + $this->assertArrayHasKey('resourceType', $variable); + $this->assertArrayHasKey('resourceId', $variable); + } + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + $this->deleteVariable($variable3['body']['$id']); + } + + public function testListVariablesWithLimit(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'LIMIT_KEY_1', + 'limit-value-1', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'LIMIT_KEY_2', + 'limit-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // List with limit of 1 + $list = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertCount(1, $list['body']['variables']); + $this->assertGreaterThanOrEqual(2, $list['body']['total']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithOffset(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'OFFSET_KEY_1', + 'offset-value-1', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'OFFSET_KEY_2', + 'offset-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // List all to get total + $listAll = $this->listVariables(null, true); + $this->assertSame(200, $listAll['headers']['status-code']); + $totalAll = \count($listAll['body']['variables']); + + // List with offset + $listOffset = $this->listVariables([ + Query::offset(1)->toString(), + ], true); + + $this->assertSame(200, $listOffset['headers']['status-code']); + $this->assertCount($totalAll - 1, $listOffset['body']['variables']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithoutTotal(): void + { + $variable = $this->createVariable( + ID::unique(), + 'NO_TOTAL_KEY', + 'no-total-value', + ); + $this->assertSame(201, $variable['headers']['status-code']); + + // List with total=false + $list = $this->listVariables(null, false); + + $this->assertSame(200, $list['headers']['status-code']); + $this->assertSame(0, $list['body']['total']); + $this->assertGreaterThanOrEqual(1, \count($list['body']['variables'])); + + // Cleanup + $this->deleteVariable($variable['body']['$id']); + } + + public function testListVariablesCursorPagination(): void + { + $variable1 = $this->createVariable( + ID::unique(), + 'CURSOR_KEY_1', + 'cursor-value-1', + ); + $this->assertSame(201, $variable1['headers']['status-code']); + + $variable2 = $this->createVariable( + ID::unique(), + 'CURSOR_KEY_2', + 'cursor-value-2', + ); + $this->assertSame(201, $variable2['headers']['status-code']); + + // Get first page with limit 1 + $page1 = $this->listVariables([ + Query::limit(1)->toString(), + ], true); + + $this->assertSame(200, $page1['headers']['status-code']); + $this->assertCount(1, $page1['body']['variables']); + $cursorId = $page1['body']['variables'][0]['$id']; + + // Get next page using cursor + $page2 = $this->listVariables([ + Query::limit(1)->toString(), + Query::cursorAfter(new Document(['$id' => $cursorId]))->toString(), + ], true); + + $this->assertSame(200, $page2['headers']['status-code']); + $this->assertCount(1, $page2['body']['variables']); + $this->assertNotEquals($cursorId, $page2['body']['variables'][0]['$id']); + + // Cleanup + $this->deleteVariable($variable1['body']['$id']); + $this->deleteVariable($variable2['body']['$id']); + } + + public function testListVariablesWithoutAuthentication(): void + { + $response = $this->listVariables(null, null, false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListVariablesInvalidCursor(): void + { + $list = $this->listVariables([ + Query::cursorAfter(new Document(['$id' => 'non-existent-id']))->toString(), + ], true); + + $this->assertSame(400, $list['headers']['status-code']); + } + + // Delete variable tests + + public function testDeleteVariable(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_KEY', + 'delete-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Verify it exists + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + $this->assertEmpty($delete['body']); + + // Verify it no longer exists + $get = $this->getVariable($variableId); + $this->assertSame(404, $get['headers']['status-code']); + $this->assertSame('variable_not_found', $get['body']['type']); + } + + public function testDeleteVariableNotFound(): void + { + $delete = $this->deleteVariable('non-existent-id'); + + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); + } + + public function testDeleteVariableWithoutAuthentication(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_AUTH_KEY', + 'delete-auth-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Attempt DELETE without authentication + $response = $this->deleteVariable($variableId, false); + + $this->assertSame(401, $response['headers']['status-code']); + + // Verify it still exists + $get = $this->getVariable($variableId); + $this->assertSame(200, $get['headers']['status-code']); + + // Cleanup + $this->deleteVariable($variableId); + } + + public function testDeleteVariableRemovedFromList(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DELETE_LIST_KEY', + 'delete-list-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // Get list count before delete + $listBefore = $this->listVariables(null, true); + $this->assertSame(200, $listBefore['headers']['status-code']); + $countBefore = $listBefore['body']['total']; + + // Delete + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Get list count after delete + $listAfter = $this->listVariables(null, true); + $this->assertSame(200, $listAfter['headers']['status-code']); + $this->assertSame($countBefore - 1, $listAfter['body']['total']); + + // Verify the deleted variable is not in the list + $ids = \array_column($listAfter['body']['variables'], '$id'); + $this->assertNotContains($variableId, $ids); + } + + public function testDeleteVariableDoubleDelete(): void + { + $variable = $this->createVariable( + ID::unique(), + 'DOUBLE_DELETE_KEY', + 'double-delete-value', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // First delete succeeds + $delete = $this->deleteVariable($variableId); + $this->assertSame(204, $delete['headers']['status-code']); + + // Second delete returns 404 + $delete = $this->deleteVariable($variableId); + $this->assertSame(404, $delete['headers']['status-code']); + $this->assertSame('variable_not_found', $delete['body']['type']); + } + + // Integration tests + + /** + * Test that project variables are available in function build and runtime. + */ + public function testProjectVariableInFunction(): void + { + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + // 1. Create a project variable + $variable = $this->createVariable( + ID::unique(), + 'GLOBAL_VARIABLE', + 'Project Variable Value', + false + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // 2. Create a function with build commands that echo the variable + $function = $this->client->call(Client::METHOD_POST, '/functions', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'functionId' => ID::unique(), + 'name' => 'Project Variable Test', + 'runtime' => 'node-22', + 'entrypoint' => 'index.js', + 'execute' => ['any'], + 'timeout' => 15, + 'commands' => 'echo $GLOBAL_VARIABLE', + ]); + + $this->assertSame(201, $function['headers']['status-code']); + $functionId = $function['body']['$id']; + + // 3. Deploy the function (basic function reads GLOBAL_VARIABLE from env) + $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'code' => $this->packageCode('functions', 'basic'), + 'activate' => true, + ]); + + $this->assertSame(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // 4. Wait for deployment to be ready and activated + $this->assertEventually(function () use ($projectId, $apiKey, $functionId, $deploymentId) { + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + + $status = $deployment['body']['status'] ?? ''; + if ($status === 'failed') { + throw new Critical('Deployment build failed: ' . ($deployment['body']['buildLogs'] ?? 'no logs')); + } + + $this->assertSame('ready', $status, 'Deployment status is not ready'); + }, 120000, 500); + + $this->assertEventually(function () use ($projectId, $apiKey, $functionId, $deploymentId) { + $function = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertSame($deploymentId, $function['body']['deploymentId'] ?? ''); + }, 120000, 500); + + // 5. Verify the project variable was available during build + $deployment = $this->client->call(Client::METHOD_GET, '/functions/' . $functionId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertSame(200, $deployment['headers']['status-code']); + $this->assertStringContainsString('Project Variable Value', $deployment['body']['buildLogs']); + + // 6. Execute the function and verify the project variable is in runtime output + $execution = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/executions', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'async' => false, + ]); + + $this->assertSame(201, $execution['headers']['status-code']); + $this->assertSame('completed', $execution['body']['status']); + $this->assertSame(200, $execution['body']['responseStatusCode']); + $output = json_decode($execution['body']['responseBody'], true); + $this->assertSame('Project Variable Value', $output['GLOBAL_VARIABLE']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/functions/' . $functionId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->deleteVariable($variableId); + } + + /** + * Test that project variables are available in site build and SSR runtime. + */ + public function testProjectVariableInSite(): void + { + $projectId = $this->getProject()['$id']; + $apiKey = $this->getProject()['apiKey']; + + // 1. Create a project variable + $variable = $this->createVariable( + ID::unique(), + 'name', + 'ProjectVarTest', + ); + + $this->assertSame(201, $variable['headers']['status-code']); + $variableId = $variable['body']['$id']; + + // 2. Create a site + $site = $this->client->call(Client::METHOD_POST, '/sites', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'siteId' => ID::unique(), + 'name' => 'Project Variable Astro Site', + 'framework' => 'astro', + 'adapter' => 'ssr', + 'buildRuntime' => 'node-22', + 'outputDirectory' => './dist', + 'buildCommand' => 'echo $name && npm run build', + 'installCommand' => 'npm ci', + 'fallbackFile' => '', + ]); + + $this->assertSame(201, $site['headers']['status-code']); + $siteId = $site['body']['$id']; + + // 3. Setup domain for proxy access + $sitesDomain = \explode(',', System::getEnv('_APP_DOMAIN_SITES', ''))[0]; + $rule = $this->client->call(Client::METHOD_POST, '/proxy/rules/site', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'domain' => ID::unique() . '.' . $sitesDomain, + 'siteId' => $siteId, + ]); + + $this->assertSame(201, $rule['headers']['status-code']); + + // 4. Deploy the site (astro site reads import.meta.env.name) + $deployment = $this->client->call(Client::METHOD_POST, '/sites/' . $siteId . '/deployments', [ + 'content-type' => 'multipart/form-data', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ], [ + 'code' => $this->packageCode('sites', 'astro'), + 'activate' => 'true', + ]); + + $this->assertSame(202, $deployment['headers']['status-code']); + $deploymentId = $deployment['body']['$id'] ?? ''; + + // 5. Wait for deployment to be ready and activated + $this->assertEventually(function () use ($projectId, $apiKey, $siteId, $deploymentId) { + $deployment = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + + $status = $deployment['body']['status'] ?? ''; + if ($status === 'failed') { + throw new Critical('Site deployment failed: ' . json_encode($deployment['body'], JSON_PRETTY_PRINT)); + } + + $this->assertSame('ready', $status, 'Deployment status is not ready'); + }, 120000, 500); + + $this->assertEventually(function () use ($projectId, $apiKey, $siteId, $deploymentId) { + $site = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertSame($deploymentId, $site['body']['deploymentId'] ?? ''); + }, 120000, 500); + + // 6. Verify the project variable was available during build + $deployment = $this->client->call(Client::METHOD_GET, '/sites/' . $siteId . '/deployments/' . $deploymentId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->assertSame(200, $deployment['headers']['status-code']); + $this->assertStringContainsString('ProjectVarTest', $deployment['body']['buildLogs']); + + // 7. Get the domain and access the site + $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('deploymentResourceId', [$siteId])->toString(), + Query::equal('trigger', ['manual'])->toString(), + Query::equal('type', ['deployment'])->toString(), + ], + ]); + + $this->assertSame(200, $rules['headers']['status-code']); + $this->assertGreaterThanOrEqual(1, \count($rules['body']['rules'])); + $domain = $rules['body']['rules'][0]['domain']; + + $proxyClient = new Client(); + $proxyClient->setEndpoint('http://' . $domain); + + $response = $proxyClient->call(Client::METHOD_GET, '/'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertStringContainsString('Env variable is ProjectVarTest', $response['body']); + $this->assertStringNotContainsString('Variable not found', $response['body']); + + // Cleanup + $this->client->call(Client::METHOD_DELETE, '/sites/' . $siteId, [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $apiKey, + ]); + $this->deleteVariable($variableId); + } + + // Helpers + + protected function createVariable(string $variableId, ?string $key, ?string $value, ?bool $secret = null, bool $authenticated = true): mixed + { + $params = [ + 'variableId' => $variableId, + ]; + + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_POST, '/project/variables', $headers, $params); + } + + protected function updateVariable(string $variableId, ?string $key = null, ?string $value = null, ?bool $secret = null, bool $authenticated = true): mixed + { + $params = []; + + if ($key !== null) { + $params['key'] = $key; + } + + if ($value !== null) { + $params['value'] = $value; + } + + if ($secret !== null) { + $params['secret'] = $secret; + } + + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_PUT, '/project/variables/' . $variableId, $headers, $params); + } + + protected function getVariable(string $variableId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/variables/' . $variableId, $headers); + } + + /** + * @param array|null $queries + */ + protected function listVariables(?array $queries, ?bool $total, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_GET, '/project/variables', $headers, [ + 'queries' => $queries, + 'total' => $total, + ]); + } + + protected function deleteVariable(string $variableId, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = array_merge($headers, $this->getHeaders()); + } + + return $this->client->call(Client::METHOD_DELETE, '/project/variables/' . $variableId, $headers); + } + + protected function packageCode(string $type, string $name): CURLFile + { + $folderPath = realpath(__DIR__ . '/../../../resources/' . $type) . "/$name"; + $tarPath = "$folderPath/code.tar.gz"; + + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); + + if (filesize($tarPath) > 1024 * 1024 * 5) { + throw new \Exception('Code package is too large. Use the chunked upload method instead.'); + } + + return new CURLFile($tarPath, 'application/x-gzip', \basename($tarPath)); + } +} diff --git a/tests/e2e/Services/Project/VariablesConsoleClientTest.php b/tests/e2e/Services/Project/VariablesConsoleClientTest.php new file mode 100644 index 0000000000..b969dd49e7 --- /dev/null +++ b/tests/e2e/Services/Project/VariablesConsoleClientTest.php @@ -0,0 +1,14 @@ +client->call(Client::METHOD_POST, '/functions/' . $functionId . '/variables', array_merge([ @@ -734,7 +734,7 @@ class WebhooksCustomServerTest extends Scope $stdout = ''; $folder = 'timeout'; $code = realpath(__DIR__ . '/../../../resources/functions') . "/{$folder}/code.tar.gz"; - Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute('cd ' . realpath(__DIR__ . "/../../../resources/functions") . "/{$folder} && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); $deployment = $this->client->call(Client::METHOD_POST, '/functions/' . $functionId . '/deployments', array_merge([ 'content-type' => 'multipart/form-data', diff --git a/tests/e2e/Services/Projects/ProjectsBase.php b/tests/e2e/Services/Projects/ProjectsBase.php index 231ec302de..ced3a0e23d 100644 --- a/tests/e2e/Services/Projects/ProjectsBase.php +++ b/tests/e2e/Services/Projects/ProjectsBase.php @@ -274,6 +274,7 @@ trait ProjectsBase 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST', 'value' => 'TESTINGVALUE', 'secret' => false @@ -288,6 +289,7 @@ trait ProjectsBase 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_1', 'value' => 'TESTINGVALUE_1', 'secret' => true diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index d4945f8407..3f84529943 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -13,6 +13,8 @@ use Tests\E2E\Scopes\SideClient; use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; +use Utopia\Database\Helpers\Role; use Utopia\Database\Query; use Utopia\System\System; @@ -106,6 +108,111 @@ class ProjectsConsoleClientTest extends Scope $this->assertEquals(401, $response['headers']['status-code']); } + public function testDeleteProjectWithMultiDB(): void + { + // Create a team and project + $team = $this->client->call(Client::METHOD_POST, '/teams', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'teamId' => ID::unique(), + 'name' => 'MultiDB Team', + ]); + + $this->assertEquals(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + $project = $this->client->call(Client::METHOD_POST, '/projects', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'projectId' => ID::unique(), + 'name' => 'MultiDB Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default') + ]); + + $this->assertEquals(201, $project['headers']['status-code']); + $projectId = $project['body']['$id']; + + $projectAdminHeaders = array_merge($this->getHeaders(), [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-mode' => 'admin', + ]); + + // Create legacy database and collection + $database = $this->client->call(Client::METHOD_POST, '/databases', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Legacy DB', + ]); + $this->assertEquals(201, $database['headers']['status-code']); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Legacy Collection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $collection['headers']['status-code']); + + // Create documentsdb database and collection + $documentsDb = $this->client->call(Client::METHOD_POST, '/documentsdb', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Documents DB', + ]); + $this->assertEquals(201, $documentsDb['headers']['status-code']); + $documentsDbId = $documentsDb['body']['$id']; + + $documentsCollection = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $documentsDbId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Documents Collection', + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $documentsCollection['headers']['status-code']); + + // Create vectorsdb database and collection + $vectorDb = $this->client->call(Client::METHOD_POST, '/vectorsdb', $projectAdminHeaders, [ + 'databaseId' => ID::unique(), + 'name' => 'Vector DB', + ]); + $this->assertEquals(201, $vectorDb['headers']['status-code']); + $vectorDbId = $vectorDb['body']['$id']; + + $vectorCollection = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $vectorDbId . '/collections', $projectAdminHeaders, [ + 'collectionId' => ID::unique(), + 'name' => 'Vector Collection', + 'dimension' => 3, + 'documentSecurity' => true, + 'permissions' => [ + Permission::create(Role::any()), + ], + ]); + $this->assertEquals(201, $vectorCollection['headers']['status-code']); + + // Delete project + $delete = $this->client->call(Client::METHOD_DELETE, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(204, $delete['headers']['status-code']); + + // Ensure project is gone + $getProject = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(404, $getProject['headers']['status-code']); + } + public function testCreateDuplicateProject(): void { // Create a team @@ -4579,6 +4686,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE', 'value' => 'TESTINGVALUE', 'secret' => false @@ -4595,6 +4703,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE_1', 'value' => 'TESTINGVALUE_1', 'secret' => true @@ -4613,6 +4722,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_CREATE', 'value' => 'ANOTHERTESTINGVALUE' ]); @@ -4625,6 +4735,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => str_repeat("A", 256), 'value' => 'TESTINGVALUE' ]); @@ -4637,6 +4748,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $data['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'LONGKEY', 'value' => str_repeat("#", 8193), ]); @@ -4782,18 +4894,6 @@ class ProjectsConsoleClientTest extends Scope $this->assertContains("APP_TEST_UPDATE", $variableKeys); $this->assertContains("APP_TEST_UPDATE_1", $variableKeys); - /** - * Test for FAILURE - */ - - $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ - 'content-type' => 'application/json', - 'x-appwrite-project' => $data['projectId'], - 'x-appwrite-mode' => 'admin', - ], $this->getHeaders())); - - $this->assertEquals(400, $response['headers']['status-code']); - $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ 'content-type' => 'application/json', 'x-appwrite-project' => $data['projectId'], @@ -4802,6 +4902,19 @@ class ProjectsConsoleClientTest extends Scope 'value' => 'TESTINGVALUEUPDATED_2' ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertSame('TESTINGVALUEUPDATED_2', $response['body']['value']); + $this->assertSame('APP_TEST_UPDATE', $response['body']['key']); + + /** + * Test for FAILURE + */ + $response = $this->client->call(Client::METHOD_PUT, '/project/variables/' . $data['variableId'], array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $data['projectId'], + 'x-appwrite-mode' => 'admin', + ], $this->getHeaders())); + $this->assertEquals(400, $response['headers']['status-code']); $longKey = str_repeat("A", 256); @@ -4851,6 +4964,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_DELETE', 'value' => 'TESTINGVALUE', 'secret' => false @@ -4865,6 +4979,7 @@ class ProjectsConsoleClientTest extends Scope 'x-appwrite-project' => $projectData['projectId'], 'x-appwrite-mode' => 'admin', ], $this->getHeaders()), [ + 'variableId' => 'unique()', 'key' => 'APP_TEST_DELETE_1', 'value' => 'TESTINGVALUE_1', 'secret' => true diff --git a/tests/e2e/Services/Proxy/ProxyBase.php b/tests/e2e/Services/Proxy/ProxyBase.php index 81b11d1041..59a853bfc8 100644 --- a/tests/e2e/Services/Proxy/ProxyBase.php +++ b/tests/e2e/Services/Proxy/ProxyBase.php @@ -271,7 +271,7 @@ trait ProxyBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); @@ -288,7 +288,7 @@ trait ProxyBase $folderPath = realpath(__DIR__ . '/../../../resources/functions') . "/$function"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $stdout, $stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $stdout, $stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index d1d7d0d054..f6200ed209 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -3884,4 +3884,1368 @@ class RealtimeCustomClientTest extends Scope } }); } + public function testChannelTablesDB() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + /** + * Test Database Create + */ + $database = $this->client->call(Client::METHOD_POST, '/tablesdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test Collection Create + */ + $actors = $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' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + $name = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/columns/string', 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->assertEquals(202, $name['headers']['status-code']); + $this->assertEquals('name', $name['body']['key']); + $this->assertEquals('string', $name['body']['type']); + $this->assertEquals(256, $name['body']['size']); + $this->assertTrue($name['body']['required']); + + sleep(2); + + /** + * Test Document Create + */ + $document = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $rowId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.tables.' . $actorsId . '.rows.' . $rowId, $response['data']['channels']); + $this->assertContains('databases.' . $databaseId . '.tables.' . $actorsId . '.rows', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Chris Evans', $response['data']['payload']['name']); + + /** + * Test Document Update + */ + $document = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans 2' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + + $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); + + /** + * Test Document Delete + */ + $document = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'rowId' => ID::unique(), + 'data' => [ + 'name' => 'Bradley Cooper' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $client->receive(); + + $rowId = $document['body']['$id']; + + $this->client->call(Client::METHOD_DELETE, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/' . $rowId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('rows', $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['channels']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.tables.{$actorsId}.rows", $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.{$rowId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.{$rowId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Bradley Cooper', $response['data']['payload']['name']); + + // test bulk create + $documents = $this->client->call(Client::METHOD_POST, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rows' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + '$id' => ID::unique(), + 'name' => 'Scarlett Johansson', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first document event + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // Receive second document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.create", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.create", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // test bulk update + $response = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $actorsId . '/rows/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'name' => 'Marvel Hero', + '$permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive second document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive third document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.update", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Test bulk delete + $response = $this->client->call(Client::METHOD_DELETE, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.delete", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive second document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive third document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // bulk upsert + $this->client->call(Client::METHOD_PUT, "/tablesdb/{$databaseId}/tables/{$actorsId}/rows", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'rows' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(8, $response['data']['channels']); + + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.*.collections.*", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("databases.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("databases.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("databases.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}.rows.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*", $response['data']['events']); + $this->assertContains("tablesdb.*.tables.{$actorsId}", $response['data']['events']); + $this->assertContains("tablesdb.{$databaseId}.tables.*.rows.*.upsert", $response['data']['events']); + $this->assertContains("tablesdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + $client->close(); + } + public function testChannelDocumentsdb() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + /** + * Test Database Create + */ + $database = $this->client->call(Client::METHOD_POST, '/documentsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors DB', + ]); + + $databaseId = $database['body']['$id']; + + /** + * Test Collection Create + */ + $actors = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + + $actorsId = $actors['body']['$id']; + + /** + * Test Document Create + */ + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $documentId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Chris Evans', $response['data']['payload']['name']); + + /** + * Test Document Update + */ + $document = $this->client->call(Client::METHOD_PATCH, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Chris Evans 2' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + + $this->assertEquals('Chris Evans 2', $response['data']['payload']['name']); + + /** + * Test Document Delete + */ + $document = $this->client->call(Client::METHOD_POST, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'name' => 'Bradley Cooper' + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $client->receive(); + + $documentId = $document['body']['$id']; + + $this->client->call(Client::METHOD_DELETE, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertEquals('Bradley Cooper', $response['data']['payload']['name']); + + // test bulk create + $documents = $this->client->call(Client::METHOD_POST, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + '$id' => ID::unique(), + 'name' => 'Scarlett Johansson', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // Receive second document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + $this->assertContains(Permission::read(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::update(Role::any()), $response['data']['payload']['$permissions']); + $this->assertContains(Permission::delete(Role::any()), $response['data']['payload']['$permissions']); + + // test bulk update + $response = $this->client->call(Client::METHOD_PATCH, '/documentsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'data' => [ + 'name' => 'Marvel Hero', + '$permissions' => [ + Permission::read(Role::user($this->getUser()['$id'])), + Permission::update(Role::user($this->getUser()['$id'])), + Permission::delete(Role::user($this->getUser()['$id'])), + ] + ], + ]); + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive second document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Receive third document update event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertEquals('Marvel Hero', $response['data']['payload']['name']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + + // Test bulk delete + $response = $this->client->call(Client::METHOD_DELETE, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ])); + + $this->assertEquals(200, $response['headers']['status-code']); + + // Receive first document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive second document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // Receive third document delete event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + // bulk upsert + $this->client->call(Client::METHOD_PUT, "/documentsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}.documents.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*", $response['data']['events']); + $this->assertContains("documentsdb.*.collections.{$actorsId}", $response['data']['events']); + $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.upsert", $response['data']['events']); + $this->assertContains("documentsdb.*", $response['data']['events']); + + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + $this->assertArrayHasKey('$id', $response['data']['payload']); + $this->assertArrayHasKey('name', $response['data']['payload']); + $this->assertArrayHasKey('$permissions', $response['data']['payload']); + $this->assertIsArray($response['data']['payload']['$permissions']); + + $client->close(); + } + + public function testChannelVectorsDB() + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $client = $this->getWebsocket(['documents', 'collections'], [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session + ]); + + $response = json_decode($client->receive(), true); + + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('connected', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertCount(2, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('collections', $response['data']['channels']); + $this->assertNotEmpty($response['data']['user']); + $this->assertEquals($user['$id'], $response['data']['user']['$id']); + + // Create VectorsDB database + $database = $this->client->call(Client::METHOD_POST, '/vectorsdb', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Actors VDB', + ]); + + $databaseId = $database['body']['$id']; + + // Create collection in VectorsDB + $actors = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + 'dimension' => 3, + ]); + + $actorsId = $actors['body']['$id']; + + // Create document in VectorsDB + $document = $this->client->call(Client::METHOD_POST, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => ID::unique(), + 'data' => [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'Chris Evans'] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + + $documentId = $document['body']['$id']; + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + // vectorsdb channels should include 3 items like documentsdb + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('documents', $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']['embeddings']); + $this->assertCount(3, $response['data']['payload']['embeddings']); + $this->assertEquals('Chris Evans', $response['data']['payload']['metadata']['name']); + + // Update document + $this->client->call(Client::METHOD_PATCH, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'data' => [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'Chris Evans 2'] + ], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']['embeddings']); + $this->assertEquals('Chris Evans 2', $response['data']['payload']['metadata']['name']); + + // Delete document + $this->client->call(Client::METHOD_DELETE, '/vectorsdb/' . $databaseId . '/collections/' . $actorsId . '/documents/' . $documentId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); + + // Bulk create two documents + $this->client->call(Client::METHOD_POST, "/vectorsdb/{$databaseId}/collections/{$actorsId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'documents' => [ + [ + 'embeddings' => [1.0, 0.0, 0.0], + 'metadata' => ['name' => 'Robert Downey Jr.'], + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + [ + 'embeddings' => [0.0, 1.0, 0.0], + 'metadata' => ['name' => 'Scarlett Johansson'], + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ] + ], + ]); + + // Receive first bulk document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); + $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); + $this->assertContains('vectorsdb.*.collections.' . $actorsId . '.documents.*.create', $response['data']['events']); + $this->assertNotEmpty($response['data']['payload']); + $this->assertIsArray($response['data']['payload']); + + // Receive second bulk document event + $response = json_decode($client->receive(), true); + $this->assertArrayHasKey('type', $response); + $this->assertArrayHasKey('data', $response); + $this->assertEquals('event', $response['type']); + $this->assertNotEmpty($response['data']); + $this->assertArrayHasKey('timestamp', $response['data']); + $this->assertCount(3, $response['data']['channels']); + $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); + + $client->close(); + } } diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php index b940dda742..c3377faad8 100644 --- a/tests/e2e/Services/Sites/SitesBase.php +++ b/tests/e2e/Services/Sites/SitesBase.php @@ -241,7 +241,7 @@ trait SitesBase $folderPath = realpath(__DIR__ . '/../../../resources/sites') . "/$site"; $tarPath = "$folderPath/code.tar.gz"; - Console::execute("cd $folderPath && tar --exclude code.tar.gz -czf code.tar.gz .", '', $this->stdout, $this->stderr); + Console::execute("cd $folderPath && tar --exclude code.tar.gz --exclude node_modules -czf code.tar.gz .", '', $this->stdout, $this->stderr); if (filesize($tarPath) > 1024 * 1024 * 5) { throw new \Exception('Code package is too large. Use the chunked upload method instead.'); diff --git a/tests/e2e/Services/Tokens/TokensBase.php b/tests/e2e/Services/Tokens/TokensBase.php index ced6bb5dde..1fcdeb347f 100644 --- a/tests/e2e/Services/Tokens/TokensBase.php +++ b/tests/e2e/Services/Tokens/TokensBase.php @@ -14,8 +14,8 @@ trait TokensBase protected function setupBucketAndFile(): array { - if (!empty(static::$bucketAndFileData)) { - return static::$bucketAndFileData; + if (!empty(self::$bucketAndFileData)) { + return self::$bucketAndFileData; } $bucket = $this->client->call( @@ -61,7 +61,7 @@ trait TokensBase ] ); - static::$bucketAndFileData = [ + self::$bucketAndFileData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'token' => $token['body'], @@ -72,7 +72,7 @@ trait TokensBase ], ]; - return static::$bucketAndFileData; + return self::$bucketAndFileData; } public function testCreateBucketAndFile(): void diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index b7f188f5b5..601bf1d2d0 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -25,8 +25,8 @@ class TokensConsoleClientTest extends Scope protected function setupToken(): array { - if (!empty(static::$tokenData)) { - return static::$tokenData; + if (!empty(self::$tokenData)) { + return self::$tokenData; } $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', array_merge([ @@ -68,13 +68,13 @@ class TokensConsoleClientTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - static::$tokenData = [ + self::$tokenData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'tokenId' => $token['body']['$id'], ]; - return static::$tokenData; + return self::$tokenData; } public function testCreateToken(): void diff --git a/tests/e2e/Services/Tokens/TokensCustomServerTest.php b/tests/e2e/Services/Tokens/TokensCustomServerTest.php index ecb9bafc89..3efa0adbe1 100644 --- a/tests/e2e/Services/Tokens/TokensCustomServerTest.php +++ b/tests/e2e/Services/Tokens/TokensCustomServerTest.php @@ -22,8 +22,8 @@ class TokensCustomServerTest extends Scope protected function setupToken(): array { - if (!empty(static::$tokenData)) { - return static::$tokenData; + if (!empty(self::$tokenData)) { + return self::$tokenData; } $bucket = $this->client->call(Client::METHOD_POST, '/storage/buckets', [ @@ -66,13 +66,13 @@ class TokensCustomServerTest extends Scope 'x-appwrite-project' => $this->getProject()['$id'] ], $this->getHeaders())); - static::$tokenData = [ + self::$tokenData = [ 'fileId' => $fileId, 'bucketId' => $bucketId, 'tokenId' => $token['body']['$id'], ]; - return static::$tokenData; + return self::$tokenData; } public function testCreateToken(): void diff --git a/tests/resources/csv/vectorsdb-documents.csv b/tests/resources/csv/vectorsdb-documents.csv new file mode 100644 index 0000000000..b0b970703e --- /dev/null +++ b/tests/resources/csv/vectorsdb-documents.csv @@ -0,0 +1,3 @@ +$id,embeddings,metadata +vector-doc-1,"[0.15,0.25,0.35]","{""title"":""Vector Alpha"",""category"":""science""}" +vector-doc-2,"[0.55,0.65,0.75]","{""title"":""Vector Beta"",""category"":""history""}" \ No newline at end of file diff --git a/tests/resources/docker/docker-compose.yml b/tests/resources/docker/docker-compose.yml index 02593f8123..47a69f077b 100644 --- a/tests/resources/docker/docker-compose.yml +++ b/tests/resources/docker/docker-compose.yml @@ -76,6 +76,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER - _APP_USAGE_STATS - _APP_STORAGE_ANTIVIRUS=disabled - _APP_STORAGE_LIMIT @@ -141,6 +142,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER appwrite-worker-tasks: entrypoint: worker-tasks @@ -162,6 +164,7 @@ services: - _APP_DB_SCHEMA - _APP_DB_USER - _APP_DB_PASS + - _APP_DB_ADAPTER appwrite-worker-deletes: entrypoint: worker-deletes @@ -182,6 +185,7 @@ services: - _APP_REDIS_HOST - _APP_REDIS_PORT - _APP_DB_HOST + - _APP_DB_ADAPTER - _APP_DB_PORT - _APP_DB_SCHEMA - _APP_DB_USER diff --git a/tests/resources/json/documents-internals.json b/tests/resources/json/documents-internals.json new file mode 100644 index 0000000000..6fe6820cdf --- /dev/null +++ b/tests/resources/json/documents-internals.json @@ -0,0 +1,254 @@ +[ + { + "$id": "z1y2x3w4v5u6t7s8", + "$createdAt": "2022-10-23T10:33:01+00:00", + "$updatedAt": "2023-03-15T12:00:41+00:00", + "$permissions": [ + "read(\"any\")", + "update(\"user:123\")" + ], + "name": "Diamond Mendez", + "age": 56 + }, + { + "$id": "r9q0p1o2n3m4l5k6", + "$createdAt": "2021-08-11T21:05:13+00:00", + "$updatedAt": "2024-01-02T08:45:22+00:00", + "$permissions": [ + "read(\"any\")", + "update(\"user:456\")" + ], + "name": "Michael Huff", + "age": 20 + }, + { + "$id": "j7i8h9g0f1e2d3c4", + "$createdAt": "2020-05-29T14:22:56+00:00", + "$updatedAt": "2022-11-30T18:19:33+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Alyssa Rodriguez", + "age": 37 + }, + { + "$id": "b5a6z7y8x9w0v1u2", + "$createdAt": "2023-01-18T03:44:09+00:00", + "$updatedAt": "2023-09-07T23:50:17+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Barbara Smith", + "age": 26 + }, + { + "$id": "t3s4r5q6p7o8n9m0", + "$createdAt": "2020-11-02T09:12:45+00:00", + "$updatedAt": "2021-07-21T15:30:55+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Evelyn Edwards", + "age": 54 + }, + { + "$id": "l1k2j3i4h5g6f7e8", + "$createdAt": "2022-03-19T19:55:27+00:00", + "$updatedAt": "2024-05-14T06:28:11+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Tina Richardson", + "age": 41 + }, + { + "$id": "d9c0b1a2z3y4x5w6", + "$createdAt": "2021-04-07T01:18:34+00:00", + "$updatedAt": "2023-06-25T11:47:04+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Joel Hernandez", + "age": 49 + }, + { + "$id": "v7u8t9s0r1q2p3o4", + "$createdAt": "2023-08-22T16:40:18+00:00", + "$updatedAt": "2024-02-19T04:09:58+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Zachary Cooper", + "age": 59 + }, + { + "$id": "n5m6l7k8j9i0h1g2", + "$createdAt": "2020-02-12T07:59:01+00:00", + "$updatedAt": "2022-09-08T13:21:49+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Brittany Spears", + "age": 20 + }, + { + "$id": "f3e4d5c6b7a8z9y0", + "$createdAt": "2021-12-05T22:33:12+00:00", + "$updatedAt": "2023-11-11T02:55:37+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Holly White", + "age": 47 + }, + { + "$id": "x1w2v3u4t5s6r7q8", + "$createdAt": "2022-07-14T05:01:50+00:00", + "$updatedAt": "2024-04-01T20:10:26+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Kimberly Barnes", + "age": 27 + }, + { + "$id": "p9o0n1m2l3k4j5i6", + "$createdAt": "2020-09-28T11:27:36+00:00", + "$updatedAt": "2021-10-17T09:38:08+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Stephen Miller", + "age": 53 + }, + { + "$id": "h7g8f9e0d1c2b3a4", + "$createdAt": "2023-04-04T08:15:59+00:00", + "$updatedAt": "2024-06-29T17:03:14+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Yvonne Newman", + "age": 41 + }, + { + "$id": "y5x6w7v8u9t0s1r2", + "$createdAt": "2021-01-25T18:09:21+00:00", + "$updatedAt": "2022-08-16T22:44:51+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Carol Kane", + "age": 38 + }, + { + "$id": "q3p4o5n6m7l8k9j0", + "$createdAt": "2022-06-09T12:53:47+00:00", + "$updatedAt": "2023-12-24T01:16:05+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Doris Foster", + "age": 44 + }, + { + "$id": "i1h2g3f4e5d6c7b8", + "$createdAt": "2020-07-03T23:37:02+00:00", + "$updatedAt": "2021-05-09T05:52:43+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Joseph Stokes", + "age": 28 + }, + { + "$id": "a9z0y1x2w3v4u5t6", + "$createdAt": "2023-10-10T02:20:15+00:00", + "$updatedAt": "2024-03-28T14:33:29+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Steve Williams", + "age": 31 + }, + { + "$id": "s7r8q9p0o1n2m3l4", + "$createdAt": "2021-06-16T13:48:53+00:00", + "$updatedAt": "2022-04-22T07:07:19+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "James Carey", + "age": 29 + }, + { + "$id": "k5j6i7h8g9f0e1d2", + "$createdAt": "2022-12-27T20:06:38+00:00", + "$updatedAt": "2023-08-03T10:25:57+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Kathryn Henry", + "age": 38 + }, + { + "$id": "c3b4a5z6y7x8w9v0", + "$createdAt": "2020-04-20T04:41:24+00:00", + "$updatedAt": "2021-02-13T19:14:06+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Christopher Landry", + "age": 23 + }, + { + "$id": "u1t2s3r4q5p6o7n8", + "$createdAt": "2023-05-08T00:58:10+00:00", + "$updatedAt": "2024-07-05T03:36:48+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Jennifer Mcgee", + "age": 62 + }, + { + "$id": "m9l0k1j2i3h4g5f6", + "$createdAt": "2021-09-01T06:11:42+00:00", + "$updatedAt": "2022-01-26T16:59:23+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Cathy Church", + "age": 35 + }, + { + "$id": "e7d8c9b0a1z2y3x4", + "$createdAt": "2022-02-18T15:24:07+00:00", + "$updatedAt": "2023-04-12T00:40:31+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Jose Lopez", + "age": 41 + }, + { + "$id": "w5v6u7t8s9r0q1p2", + "$createdAt": "2020-12-13T09:03:55+00:00", + "$updatedAt": "2021-11-06T11:23:16+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "William Rose", + "age": 30 + }, + { + "$id": "o3n4m5l6k7j8i9h0", + "$createdAt": "2021-12-13T09:03:55+00:00", + "$updatedAt": "2022-11-06T11:23:16+00:00", + "$permissions": [ + "read(\"any\")" + ], + "name": "Charles Hammer", + "age": 61 + } +] \ No newline at end of file diff --git a/tests/resources/json/documents.json b/tests/resources/json/documents.json new file mode 100644 index 0000000000..1ce5533297 --- /dev/null +++ b/tests/resources/json/documents.json @@ -0,0 +1,502 @@ +[ + { + "$id": "hxfcwpcas5xokpwe", + "name": "Diamond Mendez", + "age": 56 + }, + { + "$id": "gw8nxwf6esn3tfwf", + "name": "Michael Huff", + "age": 20 + }, + { + "$id": "xb6bxg56lral1qy9", + "name": "Alyssa Rodriguez", + "age": 37 + }, + { + "$id": "imerjq5j36y3agh2", + "name": "Barbara Smith", + "age": 26 + }, + { + "$id": "07yq9qdlhmbzmr35", + "name": "Evelyn Edwards", + "age": 54 + }, + { + "$id": "ksqo631sbhwj5ltg", + "name": "Tina Richardson", + "age": 41 + }, + { + "$id": "j7zlndgu0gbshp15", + "name": "Joel Hernandez", + "age": 49 + }, + { + "$id": "mfntvnljrcmf7h6v", + "name": "Zachary Cooper", + "age": 59 + }, + { + "$id": "5f9b01nziqu2h8ed", + "name": "Brittany Spears", + "age": 20 + }, + { + "$id": "4vxzbnzraqznk5u8", + "name": "Holly White", + "age": 47 + }, + { + "$id": "d4ywy3mtphaatbpf", + "name": "Kimberly Barnes", + "age": 27 + }, + { + "$id": "88odnk6nthyyvbal", + "name": "Stephen Miller", + "age": 53 + }, + { + "$id": "08oekee3fn7mzaa5", + "name": "Yvonne Newman", + "age": 41 + }, + { + "$id": "quw55kn9895i5e4v", + "name": "Carol Kane", + "age": 38 + }, + { + "$id": "nge6bm8ykripei6f", + "name": "Doris Foster", + "age": 44 + }, + { + "$id": "4k16i33s0xl2ypx9", + "name": "Joseph Stokes", + "age": 28 + }, + { + "$id": "q0j5rxbgid66snyf", + "name": "Steve Williams", + "age": 31 + }, + { + "$id": "n1oxun7mqq3p103y", + "name": "James Carey", + "age": 29 + }, + { + "$id": "0dbvs840jkf8i0ye", + "name": "Kathryn Henry", + "age": 38 + }, + { + "$id": "5sfaidgs1h87v15v", + "name": "Christopher Landry", + "age": 23 + }, + { + "$id": "vg3punvfu5khmf41", + "name": "Jennifer Mcgee", + "age": 62 + }, + { + "$id": "f933qydr9u5b2r11", + "name": "Cathy Church", + "age": 35 + }, + { + "$id": "wjv87y1inf8yk32s", + "name": "Jose Lopez", + "age": 41 + }, + { + "$id": "uljysdvdlcyrbrwk", + "name": "William Rose", + "age": 30 + }, + { + "$id": "ot8xtzh77j55wq0s", + "name": "Sarah Ford", + "age": 26 + }, + { + "$id": "9t76vnsv2u36s43t", + "name": "Alisha Jones", + "age": 61 + }, + { + "$id": "66y4tnty62hw8c02", + "name": "Kristin Kelly", + "age": 61 + }, + { + "$id": "2punfblazi5v16ar", + "name": "Brendan Stout", + "age": 40 + }, + { + "$id": "sxhr4nf5w2gx4wbg", + "name": "Kelly Cruz", + "age": 18 + }, + { + "$id": "68dvrqfwqnkq5el9", + "name": "Samantha Martin", + "age": 50 + }, + { + "$id": "20192l6dbeinhkh0", + "name": "David Santos", + "age": 46 + }, + { + "$id": "si0l4dgay09ebfmf", + "name": "Elizabeth Carroll", + "age": 22 + }, + { + "$id": "lhse40vbldqb6ap1", + "name": "Corey Owens", + "age": 46 + }, + { + "$id": "h5t3pslykyx3kxfm", + "name": "Shelby Mueller", + "age": 65 + }, + { + "$id": "ldc0luydrw6jub0f", + "name": "Dr. Sylvia Myers", + "age": 29 + }, + { + "$id": "voc9628xg4dsgw2y", + "name": "Scott Freeman", + "age": 48 + }, + { + "$id": "o4y0gk3gqv1ax2fz", + "name": "Christopher Atkinson", + "age": 21 + }, + { + "$id": "u1n3x4e4u7e0vzj6", + "name": "Sean Diaz", + "age": 31 + }, + { + "$id": "s36eskwtm0w7lwr7", + "name": "Bobby Dyer", + "age": 57 + }, + { + "$id": "4hjnag1p5iwvtixd", + "name": "Daniel Hall", + "age": 62 + }, + { + "$id": "m91d80oxsa216zbh", + "name": "Jennifer Ramirez", + "age": 65 + }, + { + "$id": "5hj6858zo2g85n6v", + "name": "Angela Jackson", + "age": 57 + }, + { + "$id": "8m8oihv9a1e7nn92", + "name": "Kelly Lewis", + "age": 36 + }, + { + "$id": "7azy39la0no0mxi7", + "name": "Jessica Munoz", + "age": 55 + }, + { + "$id": "47pmjkhnnqhyit8c", + "name": "Kelly George", + "age": 65 + }, + { + "$id": "6j6cpy4kgneg1mmh", + "name": "Anthony Johnson", + "age": 65 + }, + { + "$id": "tnlmtvap1zz89km9", + "name": "Regina Fields", + "age": 61 + }, + { + "$id": "6cyuvnwwqdmrpfzh", + "name": "Sharon Schaefer", + "age": 30 + }, + { + "$id": "p1v4pyu2pqodc0ey", + "name": "Jacob French", + "age": 62 + }, + { + "$id": "6npynnhjt2jd05xo", + "name": "Jessica Costa", + "age": 23 + }, + { + "$id": "wcxedf13n2e9qi4l", + "name": "George Hardy", + "age": 53 + }, + { + "$id": "yf2xlcmszk2tqeig", + "name": "Andrea Allison", + "age": 20 + }, + { + "$id": "3bf2zzv7poststwa", + "name": "Kevin Ferguson", + "age": 32 + }, + { + "$id": "c2iataz0hhv39q63", + "name": "Joseph Johnson", + "age": 58 + }, + { + "$id": "3e8npxhov4a39pvq", + "name": "Ashley Martinez", + "age": 18 + }, + { + "$id": "t7dp41tysipytywq", + "name": "Charles Nixon", + "age": 23 + }, + { + "$id": "z8cztq7c47phyfhk", + "name": "Carol Dudley", + "age": 40 + }, + { + "$id": "2636f9d8r4ipm3h6", + "name": "David Weber", + "age": 51 + }, + { + "$id": "eh3f6wxtvkjq6ykq", + "name": "Scott Robinson", + "age": 32 + }, + { + "$id": "raskbwpsje69a59h", + "name": "Anthony Hardy", + "age": 38 + }, + { + "$id": "90hn1p0b4cs9e2og", + "name": "Mackenzie Owens", + "age": 52 + }, + { + "$id": "am3swwfbo076x0v1", + "name": "Brian Foster", + "age": 27 + }, + { + "$id": "5uw7utb9lq5cfncw", + "name": "Hannah Forbes", + "age": 56 + }, + { + "$id": "cs6mbfzkzifefx6r", + "name": "Lauren Reed", + "age": 26 + }, + { + "$id": "ftw3uvztziiz9x00", + "name": "Morgan Smith", + "age": 28 + }, + { + "$id": "uhrqseeo43mozpaq", + "name": "Samantha Alexander", + "age": 65 + }, + { + "$id": "pvvmzyfc1lxor11e", + "name": "Tiffany Roberts", + "age": 20 + }, + { + "$id": "jia7bdag4abz123s", + "name": "Emily Hayes", + "age": 34 + }, + { + "$id": "h6oozcngbz8o5x4y", + "name": "Rebecca Villegas", + "age": 52 + }, + { + "$id": "9v6z1pn2f9twcy12", + "name": "Donald Shah", + "age": 61 + }, + { + "$id": "wzz3jduioso77o7f", + "name": "Denise Cain", + "age": 59 + }, + { + "$id": "u51plhgvjodkswnr", + "name": "Kristine Ramirez", + "age": 53 + }, + { + "$id": "t1uhkmiytfyc13vc", + "name": "Stacey Adkins", + "age": 61 + }, + { + "$id": "iqaqnf0ybg2ct507", + "name": "Daniel Hunt", + "age": 20 + }, + { + "$id": "idwrwv2uu4hcpv2i", + "name": "Roberta Johnson", + "age": 48 + }, + { + "$id": "2yd2hd6auetjacyo", + "name": "Jason Williamson", + "age": 39 + }, + { + "$id": "egrmdbibnjhi914x", + "name": "Sandra Robinson", + "age": 50 + }, + { + "$id": "15m1pz2bb0ercgyk", + "name": "Steve Rice", + "age": 25 + }, + { + "$id": "0i21bhkxdagjurb7", + "name": "Kimberly Fritz", + "age": 53 + }, + { + "$id": "726ofi7h5snreq67", + "name": "Brianna Reynolds", + "age": 33 + }, + { + "$id": "csqxse3wym56eim6", + "name": "Alexander Williams", + "age": 50 + }, + { + "$id": "qeaoylnrsf8p3byg", + "name": "Andrew Thomas", + "age": 25 + }, + { + "$id": "edsswobumzyzbvhf", + "name": "Austin Williams", + "age": 57 + }, + { + "$id": "hdzhzpt0ahy5hkib", + "name": "Nicholas Williams", + "age": 24 + }, + { + "$id": "w1qmvmg4roa8xnwu", + "name": "Mrs. Michelle Cisneros", + "age": 48 + }, + { + "$id": "3z3o73x7adyuo6w0", + "name": "Stacey Smith", + "age": 39 + }, + { + "$id": "sse2u5zlgoqrgmcf", + "name": "Laura Beck", + "age": 20 + }, + { + "$id": "rvovijmvch58r4yx", + "name": "Molly Clark", + "age": 51 + }, + { + "$id": "doe06nrx8sg5mcuv", + "name": "Carmen Morris", + "age": 41 + }, + { + "$id": "jbjdwuvj5s4kw04y", + "name": "Amanda Munoz", + "age": 20 + }, + { + "$id": "6k2ewkla7js0yw23", + "name": "Rachel Collins", + "age": 44 + }, + { + "$id": "fcxuyr4kkhrnigu1", + "name": "John Alexander", + "age": 18 + }, + { + "$id": "d25fuwlos5mk07o0", + "name": "Stacy Hunter", + "age": 22 + }, + { + "$id": "1vdai2rxmwd57oet", + "name": "Eric Massey", + "age": 40 + }, + { + "$id": "pq4jnt9izu1wlrzd", + "name": "Scott Garcia", + "age": 20 + }, + { + "$id": "lz9kfc0lty5xcz14", + "name": "Cassandra Nelson", + "age": 35 + }, + { + "$id": "pu7w6tyab5jd4we9", + "name": "Aaron Johnson", + "age": 50 + }, + { + "$id": "8dupswd2kqwdyn8v", + "name": "Shannon Sherman", + "age": 45 + }, + { + "$id": "ye466l71jthiz2p6", + "name": "April Garcia", + "age": 60 + }, + { + "$id": "xogsmfwb73l16qdt", + "name": "Evan Lynn", + "age": 20 + } +] \ No newline at end of file diff --git a/tests/resources/json/documentsdb-documents.json b/tests/resources/json/documentsdb-documents.json new file mode 100644 index 0000000000..908c123e81 --- /dev/null +++ b/tests/resources/json/documentsdb-documents.json @@ -0,0 +1,162 @@ +[ + { + "$id": "doc01", + "name": "Alice", + "email": "alice@test.com", + "age": 35, + "active": false, + "tags": [ + "analyst", + "engineer" + ], + "address": { + "city": "New York", + "zip": "27436", + "country": "DE" + } + }, + { + "$id": "doc02", + "name": "Bob", + "email": "bob@test.com", + "age": 58, + "active": false, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "London", + "zip": "49320", + "country": "JP" + } + }, + { + "$id": "doc03", + "name": "Charlie", + "email": "charlie@test.com", + "age": 23, + "active": true, + "tags": [ + "analyst", + "manager" + ], + "address": { + "city": "Tokyo", + "zip": "74758", + "country": "JP" + } + }, + { + "$id": "doc04", + "name": "Diana", + "email": "diana@test.com", + "age": 60, + "active": true, + "tags": [ + "analyst", + "manager" + ], + "address": { + "city": "Paris", + "zip": "50703", + "country": "JP" + } + }, + { + "$id": "doc05", + "name": "Eve", + "email": "eve@test.com", + "age": 59, + "active": false, + "tags": [ + "engineer", + "designer" + ], + "address": { + "city": "Berlin", + "zip": "98929", + "country": "UK" + } + }, + { + "$id": "doc06", + "name": "Frank", + "email": "frank@test.com", + "age": 23, + "active": true, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "Sydney", + "zip": "82907", + "country": "US" + } + }, + { + "$id": "doc07", + "name": "Grace", + "email": "grace@test.com", + "age": 36, + "active": true, + "tags": [ + "analyst", + "developer" + ], + "address": { + "city": "Toronto", + "zip": "68710", + "country": "US" + } + }, + { + "$id": "doc08", + "name": "Hank", + "email": "hank@test.com", + "age": 28, + "active": false, + "tags": [ + "engineer", + "manager" + ], + "address": { + "city": "Mumbai", + "zip": "61026", + "country": "FR" + } + }, + { + "$id": "doc09", + "name": "Iris", + "email": "iris@test.com", + "age": 40, + "active": true, + "tags": [ + "developer", + "designer" + ], + "address": { + "city": "Seoul", + "zip": "48039", + "country": "FR" + } + }, + { + "$id": "doc10", + "name": "Jack", + "email": "jack@test.com", + "age": 45, + "active": false, + "tags": [ + "manager", + "developer" + ], + "address": { + "city": "Dubai", + "zip": "22733", + "country": "JP" + } + } +] \ No newline at end of file diff --git a/tests/resources/json/irrelevant-column.json b/tests/resources/json/irrelevant-column.json new file mode 100644 index 0000000000..d047620ab0 --- /dev/null +++ b/tests/resources/json/irrelevant-column.json @@ -0,0 +1,602 @@ +[ + { + "$id": "r5ctmrqwqn1m3rc0", + "name": "Diamond Mendez", + "age": 56, + "email": "diamond.mendez@example.com" + }, + { + "$id": "wxwp7e7q7nx3ltfx", + "name": "Michael Huff", + "age": 20, + "email": "michael.huff@example.com" + }, + { + "$id": "4ct0b38fwaojawlv", + "name": "Alyssa Rodriguez", + "age": 37, + "email": "alyssa.rodriguez@example.com" + }, + { + "$id": "o0jjcuygbta2zvga", + "name": "Barbara Smith", + "age": 26, + "email": "barbara.smith@example.com" + }, + { + "$id": "bdy6l2ofl8klb4pb", + "name": "Evelyn Edwards", + "age": 54, + "email": "evelyn.edwards@example.com" + }, + { + "$id": "rkccl72v7zwtbila", + "name": "Tina Richardson", + "age": 41, + "email": "tina.richardson@example.com" + }, + { + "$id": "cilw7um0cd927esj", + "name": "Joel Hernandez", + "age": 49, + "email": "joel.hernandez@example.com" + }, + { + "$id": "60povvz0votkve1j", + "name": "Zachary Cooper", + "age": 59, + "email": "zachary.cooper@example.com" + }, + { + "$id": "ayow5dzwktvbbtp2", + "name": "Brittany Spears", + "age": 20, + "email": "brittany.spears@example.com" + }, + { + "$id": "cfru98od0lab0b2n", + "name": "Holly White", + "age": 47, + "email": "holly.white@example.com" + }, + { + "$id": "vjxjldvu3r6uylq6", + "name": "Kimberly Barnes", + "age": 27, + "email": "kimberly.barnes@example.com" + }, + { + "$id": "d1p47hl97pw6xowb", + "name": "Stephen Miller", + "age": 53, + "email": "stephen.miller@example.com" + }, + { + "$id": "yxk6qaa5ryb3gqrb", + "name": "Yvonne Newman", + "age": 41, + "email": "yvonne.newman@example.com" + }, + { + "$id": "ifkeo7j8t7hfd7z8", + "name": "Carol Kane", + "age": 38, + "email": "carol.kane@example.com" + }, + { + "$id": "e1q4lq8vvpxp9ysb", + "name": "Doris Foster", + "age": 44, + "email": "doris.foster@example.com" + }, + { + "$id": "obec6d52dc6swzsf", + "name": "Joseph Stokes", + "age": 28, + "email": "joseph.stokes@example.com" + }, + { + "$id": "26v06la6ug8wkvim", + "name": "Steve Williams", + "age": 31, + "email": "steve.williams@example.com" + }, + { + "$id": "m4glre4ch12vkxp6", + "name": "James Carey", + "age": 29, + "email": "james.carey@example.com" + }, + { + "$id": "jee8fyfffnjugsd5", + "name": "Kathryn Henry", + "age": 38, + "email": "kathryn.henry@example.com" + }, + { + "$id": "miquc6ljb9l3a31r", + "name": "Christopher Landry", + "age": 23, + "email": "christopher.landry@example.com" + }, + { + "$id": "ghf7da7seeuj1zdl", + "name": "Jennifer Mcgee", + "age": 62, + "email": "jennifer.mcgee@example.com" + }, + { + "$id": "x7h11phjrz77w0q8", + "name": "Cathy Church", + "age": 35, + "email": "cathy.church@example.com" + }, + { + "$id": "dn8u5lsux4708z6j", + "name": "Jose Lopez", + "age": 41, + "email": "jose.lopez@example.com" + }, + { + "$id": "zb7fdlyohuyy5i9k", + "name": "William Rose", + "age": 30, + "email": "william.rose@example.com" + }, + { + "$id": "qyrj8m9krp4dt4wt", + "name": "Sarah Ford", + "age": 26, + "email": "sarah.ford@example.com" + }, + { + "$id": "t6t673zpfyhhz8pg", + "name": "Alisha Jones", + "age": 61, + "email": "alisha.jones@example.com" + }, + { + "$id": "0hfbo0iy1q9bwc2n", + "name": "Kristin Kelly", + "age": 61, + "email": "kristin.kelly@example.com" + }, + { + "$id": "8alv4e4xrpcj443z", + "name": "Brendan Stout", + "age": 40, + "email": "brendan.stout@example.com" + }, + { + "$id": "qxm7a0z32xdkzxdj", + "name": "Kelly Cruz", + "age": 18, + "email": "kelly.cruz@example.com" + }, + { + "$id": "885mti7j7oiz5p5g", + "name": "Samantha Martin", + "age": 50, + "email": "samantha.martin@example.com" + }, + { + "$id": "v8i7dvhby6711m66", + "name": "David Santos", + "age": 46, + "email": "david.santos@example.com" + }, + { + "$id": "rggc0ow8ccd2jgvp", + "name": "Elizabeth Carroll", + "age": 22, + "email": "elizabeth.carroll@example.com" + }, + { + "$id": "012472s64rvzq1c4", + "name": "Corey Owens", + "age": 46, + "email": "corey.owens@example.com" + }, + { + "$id": "0k2xrwj4g33ut14y", + "name": "Shelby Mueller", + "age": 65, + "email": "shelby.mueller@example.com" + }, + { + "$id": "s3y9rl4uzf3difiq", + "name": "Dr. Sylvia Myers", + "age": 29, + "email": "sylvia.myers@example.com" + }, + { + "$id": "ntpc2td892t7f6an", + "name": "Scott Freeman", + "age": 48, + "email": "scott.freeman@example.com" + }, + { + "$id": "7f703gibyr5ijdmt", + "name": "Christopher Atkinson", + "age": 21, + "email": "christopher.atkinson@example.com" + }, + { + "$id": "r2jdf2pivkxmqd0l", + "name": "Sean Diaz", + "age": 31, + "email": "sean.diaz@example.com" + }, + { + "$id": "fj98fji1lrxeigs9", + "name": "Bobby Dyer", + "age": 57, + "email": "bobby.dyer@example.com" + }, + { + "$id": "mehqmzp9u7xv1z3j", + "name": "Daniel Hall", + "age": 62, + "email": "daniel.hall@example.com" + }, + { + "$id": "4cd5ln65qjfv3h4j", + "name": "Jennifer Ramirez", + "age": 65, + "email": "jennifer.ramirez@example.com" + }, + { + "$id": "wdi6ap0oa7m1ab1d", + "name": "Angela Jackson", + "age": 57, + "email": "angela.jackson@example.com" + }, + { + "$id": "l2foqjhxvjhjzijb", + "name": "Kelly Lewis", + "age": 36, + "email": "kelly.lewis@example.com" + }, + { + "$id": "d963t5yu35uagwm4", + "name": "Jessica Munoz", + "age": 55, + "email": "jessica.munoz@example.com" + }, + { + "$id": "99ez9uxsim8zp64m", + "name": "Kelly George", + "age": 65, + "email": "kelly.george@example.com" + }, + { + "$id": "v7wl221gycftl63d", + "name": "Anthony Johnson", + "age": 65, + "email": "anthony.johnson@example.com" + }, + { + "$id": "p2zzj0lnmjvqzfc3", + "name": "Regina Fields", + "age": 61, + "email": "regina.fields@example.com" + }, + { + "$id": "fk655e243z2ivvx6", + "name": "Sharon Schaefer", + "age": 30, + "email": "sharon.schaefer@example.com" + }, + { + "$id": "4ywsv6fw8g2d8ncw", + "name": "Jacob French", + "age": 62, + "email": "jacob.french@example.com" + }, + { + "$id": "y61q9k6g4h0fxxz4", + "name": "Jessica Costa", + "age": 23, + "email": "jessica.costa@example.com" + }, + { + "$id": "knj4hfzsthk7vx5n", + "name": "George Hardy", + "age": 53, + "email": "george.hardy@example.com" + }, + { + "$id": "a88u9w2pct2nn8l6", + "name": "Andrea Allison", + "age": 20, + "email": "andrea.allison@example.com" + }, + { + "$id": "hw960v1ybycrwr5o", + "name": "Kevin Ferguson", + "age": 32, + "email": "kevin.ferguson@example.com" + }, + { + "$id": "j9garslpgx6jgzgb", + "name": "Joseph Johnson", + "age": 58, + "email": "joseph.johnson@example.com" + }, + { + "$id": "gv101bz36elm84cd", + "name": "Ashley Martinez", + "age": 18, + "email": "ashley.martinez@example.com" + }, + { + "$id": "xrvzgt3gc0c7g4cl", + "name": "Charles Nixon", + "age": 23, + "email": "charles.nixon@example.com" + }, + { + "$id": "awjlu7uk0eutcfpb", + "name": "Carol Dudley", + "age": 40, + "email": "carol.dudley@example.com" + }, + { + "$id": "95oi26p2zdudpime", + "name": "David Weber", + "age": 51, + "email": "david.weber@example.com" + }, + { + "$id": "h8x7pkhdvu5bcp89", + "name": "Scott Robinson", + "age": 32, + "email": "scott.robinson@example.com" + }, + { + "$id": "oj6cu4jm1z2afe7s", + "name": "Anthony Hardy", + "age": 38, + "email": "anthony.hardy@example.com" + }, + { + "$id": "hgsdi1g30poqqmf0", + "name": "Mackenzie Owens", + "age": 52, + "email": "mackenzie.owens@example.com" + }, + { + "$id": "8fzdz914bqlqk2tc", + "name": "Brian Foster", + "age": 27, + "email": "brian.foster@example.com" + }, + { + "$id": "fwlqoeiunjhczpl0", + "name": "Hannah Forbes", + "age": 56, + "email": "hannah.forbes@example.com" + }, + { + "$id": "rsv8156goe8z4j6j", + "name": "Lauren Reed", + "age": 26, + "email": "lauren.reed@example.com" + }, + { + "$id": "1fjqv3w7uwbswe2p", + "name": "Morgan Smith", + "age": 28, + "email": "morgan.smith@example.com" + }, + { + "$id": "soqrzmhhg05hhzn4", + "name": "Samantha Alexander", + "age": 65, + "email": "samantha.alexander@example.com" + }, + { + "$id": "8quy52cto9kjjokp", + "name": "Tiffany Roberts", + "age": 20, + "email": "tiffany.roberts@example.com" + }, + { + "$id": "e3i1g1lw04v7jd89", + "name": "Emily Hayes", + "age": 34, + "email": "emily.hayes@example.com" + }, + { + "$id": "s7n8lzb0sw7h93z1", + "name": "Rebecca Villegas", + "age": 52, + "email": "rebecca.villegas@example.com" + }, + { + "$id": "e2lc7i81tpkqs1rp", + "name": "Donald Shah", + "age": 61, + "email": "donald.shah@example.com" + }, + { + "$id": "3oe2mysup1xluiw0", + "name": "Denise Cain", + "age": 59, + "email": "denise.cain@example.com" + }, + { + "$id": "1vqypc37f85nuqz4", + "name": "Kristine Ramirez", + "age": 53, + "email": "kristine.ramirez@example.com" + }, + { + "$id": "m0uh7r3dc6z8ucb4", + "name": "Stacey Adkins", + "age": 61, + "email": "stacey.adkins@example.com" + }, + { + "$id": "jdofz6x1ahganmqf", + "name": "Daniel Hunt", + "age": 20, + "email": "daniel.hunt@example.com" + }, + { + "$id": "vbe903c2q4m4q97g", + "name": "Roberta Johnson", + "age": 48, + "email": "roberta.johnson@example.com" + }, + { + "$id": "sndngrxuwpd93pdb", + "name": "Jason Williamson", + "age": 39, + "email": "jason.williamson@example.com" + }, + { + "$id": "66hvaw2p5xwf07p8", + "name": "Sandra Robinson", + "age": 50, + "email": "sandra.robinson@example.com" + }, + { + "$id": "9pvingfsl8cmag5c", + "name": "Steve Rice", + "age": 25, + "email": "steve.rice@example.com" + }, + { + "$id": "qe154m5hh00u4iiz", + "name": "Kimberly Fritz", + "age": 53, + "email": "kimberly.fritz@example.com" + }, + { + "$id": "avqnbrco2f0tfupk", + "name": "Brianna Reynolds", + "age": 33, + "email": "brianna.reynolds@example.com" + }, + { + "$id": "cqs10gi2qu1r3ugb", + "name": "Alexander Williams", + "age": 50, + "email": "alexander.williams@example.com" + }, + { + "$id": "jrpmfi6hmm7pmegp", + "name": "Andrew Thomas", + "age": 25, + "email": "andrew.thomas@example.com" + }, + { + "$id": "heeab2qqf0zm446f", + "name": "Austin Williams", + "age": 57, + "email": "austin.williams@example.com" + }, + { + "$id": "bkhugvnil7kjchm6", + "name": "Nicholas Williams", + "age": 24, + "email": "nicholas.williams@example.com" + }, + { + "$id": "b045j302pvv8l1p4", + "name": "Mrs. Michelle Cisneros", + "age": 48, + "email": "michelle.cisneros@example.com" + }, + { + "$id": "aikhii5q210lrfpr", + "name": "Stacey Smith", + "age": 39, + "email": "stacey.smith@example.com" + }, + { + "$id": "x0zajitea1z2dfo0", + "name": "Laura Beck", + "age": 20, + "email": "laura.beck@example.com" + }, + { + "$id": "abeecki7mdff1tv0", + "name": "Molly Clark", + "age": 51, + "email": "molly.clark@example.com" + }, + { + "$id": "yizama8r3i1to548", + "name": "Carmen Morris", + "age": 41, + "email": "carmen.morris@example.com" + }, + { + "$id": "8690yh971g4rgspj", + "name": "Amanda Munoz", + "age": 20, + "email": "amanda.munoz@example.com" + }, + { + "$id": "cd9vk5v97t359ul2", + "name": "Rachel Collins", + "age": 44, + "email": "rachel.collins@example.com" + }, + { + "$id": "wrkgmx1v0w9ja4l8", + "name": "John Alexander", + "age": 18, + "email": "john.alexander@example.com" + }, + { + "$id": "kxp3ucqo6ped4ss7", + "name": "Stacy Hunter", + "age": 22, + "email": "stacy.hunter@example.com" + }, + { + "$id": "dbvv8okae2qgo0gm", + "name": "Eric Massey", + "age": 40, + "email": "eric.massey@example.com" + }, + { + "$id": "9tn3nm6ppnayisje", + "name": "Scott Garcia", + "age": 20, + "email": "scott.garcia@example.com" + }, + { + "$id": "1xuc5t60xpcvd4qi", + "name": "Cassandra Nelson", + "age": 35, + "email": "cassandra.nelson@example.com" + }, + { + "$id": "qao1nulwn0kqyfkc", + "name": "Aaron Johnson", + "age": 50, + "email": "aaron.johnson@example.com" + }, + { + "$id": "kd2q6owvuwsy5knx", + "name": "Shannon Sherman", + "age": 45, + "email": "shannon.sherman@example.com" + }, + { + "$id": "wsl37kjo0bib4wrc", + "name": "April Garcia", + "age": 60, + "email": "april.garcia@example.com" + }, + { + "$id": "ujlz7k84xzfx4khs", + "name": "Evan Lynn", + "age": 20, + "email": "evan.lynn@example.com" + } +] \ No newline at end of file diff --git a/tests/resources/json/missing-column.json b/tests/resources/json/missing-column.json new file mode 100644 index 0000000000..ac7a8e1a85 --- /dev/null +++ b/tests/resources/json/missing-column.json @@ -0,0 +1,402 @@ +[ + { + "$id": "hxfcwpcas5xokpwe", + "name": "Diamond Mendez" + }, + { + "$id": "gw8nxwf6esn3tfwf", + "name": "Michael Huff" + }, + { + "$id": "xb6bxg56lral1qy9", + "name": "Alyssa Rodriguez" + }, + { + "$id": "imerjq5j36y3agh2", + "name": "Barbara Smith" + }, + { + "$id": "07yq9qdlhmbzmr35", + "name": "Evelyn Edwards" + }, + { + "$id": "ksqo631sbhwj5ltg", + "name": "Tina Richardson" + }, + { + "$id": "j7zlndgu0gbshp15", + "name": "Joel Hernandez" + }, + { + "$id": "mfntvnljrcmf7h6v", + "name": "Zachary Cooper" + }, + { + "$id": "5f9b01nziqu2h8ed", + "name": "Brittany Spears" + }, + { + "$id": "4vxzbnzraqznk5u8", + "name": "Holly White" + }, + { + "$id": "d4ywy3mtphaatbpf", + "name": "Kimberly Barnes" + }, + { + "$id": "88odnk6nthyyvbal", + "name": "Stephen Miller" + }, + { + "$id": "08oekee3fn7mzaa5", + "name": "Yvonne Newman" + }, + { + "$id": "quw55kn9895i5e4v", + "name": "Carol Kane" + }, + { + "$id": "nge6bm8ykripei6f", + "name": "Doris Foster" + }, + { + "$id": "4k16i33s0xl2ypx9", + "name": "Joseph Stokes" + }, + { + "$id": "q0j5rxbgid66snyf", + "name": "Steve Williams" + }, + { + "$id": "n1oxun7mqq3p103y", + "name": "James Carey" + }, + { + "$id": "0dbvs840jkf8i0ye", + "name": "Kathryn Henry" + }, + { + "$id": "5sfaidgs1h87v15v", + "name": "Christopher Landry" + }, + { + "$id": "vg3punvfu5khmf41", + "name": "Jennifer Mcgee" + }, + { + "$id": "f933qydr9u5b2r11", + "name": "Cathy Church" + }, + { + "$id": "wjv87y1inf8yk32s", + "name": "Jose Lopez" + }, + { + "$id": "uljysdvdlcyrbrwk", + "name": "William Rose" + }, + { + "$id": "ot8xtzh77j55wq0s", + "name": "Sarah Ford" + }, + { + "$id": "9t76vnsv2u36s43t", + "name": "Alisha Jones" + }, + { + "$id": "66y4tnty62hw8c02", + "name": "Kristin Kelly" + }, + { + "$id": "2punfblazi5v16ar", + "name": "Brendan Stout" + }, + { + "$id": "sxhr4nf5w2gx4wbg", + "name": "Kelly Cruz" + }, + { + "$id": "68dvrqfwqnkq5el9", + "name": "Samantha Martin" + }, + { + "$id": "20192l6dbeinhkh0", + "name": "David Santos" + }, + { + "$id": "si0l4dgay09ebfmf", + "name": "Elizabeth Carroll" + }, + { + "$id": "lhse40vbldqb6ap1", + "name": "Corey Owens" + }, + { + "$id": "h5t3pslykyx3kxfm", + "name": "Shelby Mueller" + }, + { + "$id": "ldc0luydrw6jub0f", + "name": "Dr. Sylvia Myers" + }, + { + "$id": "voc9628xg4dsgw2y", + "name": "Scott Freeman" + }, + { + "$id": "o4y0gk3gqv1ax2fz", + "name": "Christopher Atkinson" + }, + { + "$id": "u1n3x4e4u7e0vzj6", + "name": "Sean Diaz" + }, + { + "$id": "s36eskwtm0w7lwr7", + "name": "Bobby Dyer" + }, + { + "$id": "4hjnag1p5iwvtixd", + "name": "Daniel Hall" + }, + { + "$id": "m91d80oxsa216zbh", + "name": "Jennifer Ramirez" + }, + { + "$id": "5hj6858zo2g85n6v", + "name": "Angela Jackson" + }, + { + "$id": "8m8oihv9a1e7nn92", + "name": "Kelly Lewis" + }, + { + "$id": "7azy39la0no0mxi7", + "name": "Jessica Munoz" + }, + { + "$id": "47pmjkhnnqhyit8c", + "name": "Kelly George" + }, + { + "$id": "6j6cpy4kgneg1mmh", + "name": "Anthony Johnson" + }, + { + "$id": "tnlmtvap1zz89km9", + "name": "Regina Fields" + }, + { + "$id": "6cyuvnwwqdmrpfzh", + "name": "Sharon Schaefer" + }, + { + "$id": "p1v4pyu2pqodc0ey", + "name": "Jacob French" + }, + { + "$id": "6npynnhjt2jd05xo", + "name": "Jessica Costa" + }, + { + "$id": "wcxedf13n2e9qi4l", + "name": "George Hardy" + }, + { + "$id": "yf2xlcmszk2tqeig", + "name": "Andrea Allison" + }, + { + "$id": "3bf2zzv7poststwa", + "name": "Kevin Ferguson" + }, + { + "$id": "c2iataz0hhv39q63", + "name": "Joseph Johnson" + }, + { + "$id": "3e8npxhov4a39pvq", + "name": "Ashley Martinez" + }, + { + "$id": "t7dp41tysipytywq", + "name": "Charles Nixon" + }, + { + "$id": "z8cztq7c47phyfhk", + "name": "Carol Dudley" + }, + { + "$id": "2636f9d8r4ipm3h6", + "name": "David Weber" + }, + { + "$id": "eh3f6wxtvkjq6ykq", + "name": "Scott Robinson" + }, + { + "$id": "raskbwpsje69a59h", + "name": "Anthony Hardy" + }, + { + "$id": "90hn1p0b4cs9e2og", + "name": "Mackenzie Owens" + }, + { + "$id": "am3swwfbo076x0v1", + "name": "Brian Foster" + }, + { + "$id": "5uw7utb9lq5cfncw", + "name": "Hannah Forbes" + }, + { + "$id": "cs6mbfzkzifefx6r", + "name": "Lauren Reed" + }, + { + "$id": "ftw3uvztziiz9x00", + "name": "Morgan Smith" + }, + { + "$id": "uhrqseeo43mozpaq", + "name": "Samantha Alexander" + }, + { + "$id": "pvvmzyfc1lxor11e", + "name": "Tiffany Roberts" + }, + { + "$id": "jia7bdag4abz123s", + "name": "Emily Hayes" + }, + { + "$id": "h6oozcngbz8o5x4y", + "name": "Rebecca Villegas" + }, + { + "$id": "9v6z1pn2f9twcy12", + "name": "Donald Shah" + }, + { + "$id": "wzz3jduioso77o7f", + "name": "Denise Cain" + }, + { + "$id": "u51plhgvjodkswnr", + "name": "Kristine Ramirez" + }, + { + "$id": "t1uhkmiytfyc13vc", + "name": "Stacey Adkins" + }, + { + "$id": "iqaqnf0ybg2ct507", + "name": "Daniel Hunt" + }, + { + "$id": "idwrwv2uu4hcpv2i", + "name": "Roberta Johnson" + }, + { + "$id": "2yd2hd6auetjacyo", + "name": "Jason Williamson" + }, + { + "$id": "egrmdbibnjhi914x", + "name": "Sandra Robinson" + }, + { + "$id": "15m1pz2bb0ercgyk", + "name": "Steve Rice" + }, + { + "$id": "0i21bhkxdagjurb7", + "name": "Kimberly Fritz" + }, + { + "$id": "726ofi7h5snreq67", + "name": "Brianna Reynolds" + }, + { + "$id": "csqxse3wym56eim6", + "name": "Alexander Williams" + }, + { + "$id": "qeaoylnrsf8p3byg", + "name": "Andrew Thomas" + }, + { + "$id": "edsswobumzyzbvhf", + "name": "Austin Williams" + }, + { + "$id": "hdzhzpt0ahy5hkib", + "name": "Nicholas Williams" + }, + { + "$id": "w1qmvmg4roa8xnwu", + "name": "Mrs. Michelle Cisneros" + }, + { + "$id": "3z3o73x7adyuo6w0", + "name": "Stacey Smith" + }, + { + "$id": "sse2u5zlgoqrgmcf", + "name": "Laura Beck" + }, + { + "$id": "rvovijmvch58r4yx", + "name": "Molly Clark" + }, + { + "$id": "doe06nrx8sg5mcuv", + "name": "Carmen Morris" + }, + { + "$id": "jbjdwuvj5s4kw04y", + "name": "Amanda Munoz" + }, + { + "$id": "6k2ewkla7js0yw23", + "name": "Rachel Collins" + }, + { + "$id": "fcxuyr4kkhrnigu1", + "name": "John Alexander" + }, + { + "$id": "d25fuwlos5mk07o0", + "name": "Stacy Hunter" + }, + { + "$id": "1vdai2rxmwd57oet", + "name": "Eric Massey" + }, + { + "$id": "pq4jnt9izu1wlrzd", + "name": "Scott Garcia" + }, + { + "$id": "lz9kfc0lty5xcz14", + "name": "Cassandra Nelson" + }, + { + "$id": "pu7w6tyab5jd4we9", + "name": "Aaron Johnson" + }, + { + "$id": "8dupswd2kqwdyn8v", + "name": "Shannon Sherman" + }, + { + "$id": "ye466l71jthiz2p6", + "name": "April Garcia" + }, + { + "$id": "xogsmfwb73l16qdt", + "name": "Evan Lynn" + } +] \ No newline at end of file diff --git a/tests/resources/json/vectorsdb-documents.json b/tests/resources/json/vectorsdb-documents.json new file mode 100644 index 0000000000..f1bada8a7e --- /dev/null +++ b/tests/resources/json/vectorsdb-documents.json @@ -0,0 +1,262 @@ +[ + { + "$id": "vec01", + "metadata": { + "title": "Vector Document 1", + "score": 0.643, + "category": "art" + }, + "embeddings": [ + -0.516361, + 0.261456, + -0.77323, + 0.909775, + -0.003065, + -0.626888, + 0.704171, + -0.366385, + 0.198681, + 0.854914, + -0.227548, + -0.14278, + -0.548405, + -0.822939, + 0.407267, + 0.570791 + ] + }, + { + "$id": "vec02", + "metadata": { + "title": "Vector Document 2", + "score": 0.322, + "category": "art" + }, + "embeddings": [ + 0.103211, + 0.028378, + 0.806001, + -0.412638, + -0.200009, + 0.448239, + -0.781144, + 0.621957, + -0.868352, + 0.217013, + 0.308217, + -0.851528, + -0.340766, + 0.805996, + -0.065638, + -0.363951 + ] + }, + { + "$id": "vec03", + "metadata": { + "title": "Vector Document 3", + "score": 0.503, + "category": "science" + }, + "embeddings": [ + 0.971901, + -0.475154, + 0.60526, + -0.151618, + -0.938199, + -0.139977, + 0.154581, + -0.868021, + 0.069567, + 0.233545, + -0.164826, + 0.239036, + 0.8875, + 0.488075, + 0.787231, + 0.090293 + ] + }, + { + "$id": "vec04", + "metadata": { + "title": "Vector Document 4", + "score": 0.943, + "category": "science" + }, + "embeddings": [ + 0.372035, + -0.868405, + -0.974987, + -0.836673, + 0.030057, + -0.667698, + 0.095189, + 0.518373, + -0.173732, + 0.966379, + 0.509861, + -0.172607, + 0.420855, + -0.534562, + 0.600872, + 0.194391 + ] + }, + { + "$id": "vec05", + "metadata": { + "title": "Vector Document 5", + "score": 0.924, + "category": "music" + }, + "embeddings": [ + -0.318783, + -0.876486, + 0.843971, + -0.472869, + -0.350164, + 0.936237, + -0.741591, + 0.298213, + -0.075429, + 0.243415, + 0.929625, + 0.96649, + 0.251843, + -0.371953, + 0.348079, + 0.090382 + ] + }, + { + "$id": "vec06", + "metadata": { + "title": "Vector Document 6", + "score": 0.385, + "category": "science" + }, + "embeddings": [ + -0.608111, + 0.549885, + 0.720346, + 0.260442, + -0.023344, + 0.800346, + -0.956586, + -0.407161, + 0.516883, + 0.230456, + 0.376704, + -0.347203, + 0.925657, + -0.486608, + 0.330032, + 0.323414 + ] + }, + { + "$id": "vec07", + "metadata": { + "title": "Vector Document 7", + "score": 0.61, + "category": "science" + }, + "embeddings": [ + 0.45451, + -0.251783, + -0.479459, + 0.167412, + 0.890343, + 0.415136, + -0.506263, + 0.318171, + -0.729294, + -0.907919, + 0.607033, + 0.652333, + 0.97129, + -0.118671, + 0.110045, + -0.514242 + ] + }, + { + "$id": "vec08", + "metadata": { + "title": "Vector Document 8", + "score": 0.107, + "category": "history" + }, + "embeddings": [ + -0.883255, + -0.294593, + -0.153187, + 0.595936, + 0.341818, + -0.410719, + 0.017348, + -0.350047, + -0.888906, + 0.344646, + 0.184335, + -0.429053, + 0.540542, + -0.408493, + -0.982748, + -0.255031 + ] + }, + { + "$id": "vec09", + "metadata": { + "title": "Vector Document 9", + "score": 0.914, + "category": "art" + }, + "embeddings": [ + 0.31891, + 0.502493, + -0.245712, + -0.325282, + -0.514772, + 0.097323, + 0.812412, + -0.762069, + 0.846688, + -0.391482, + 0.879953, + 0.986352, + -0.562588, + -0.566111, + 0.163109, + 0.119346 + ] + }, + { + "$id": "vec10", + "metadata": { + "title": "Vector Document 10", + "score": 0.169, + "category": "science" + }, + "embeddings": [ + -0.563645, + -0.19119, + -0.853168, + -0.526952, + -0.459635, + 0.850766, + -0.345261, + 0.547467, + 0.455409, + -0.507607, + -0.228436, + -0.587395, + 0.109146, + 0.190046, + 0.861559, + -0.724741 + ] + } +] \ No newline at end of file diff --git a/tests/resources/postgresql/Dockerfile b/tests/resources/postgresql/Dockerfile deleted file mode 100644 index a731833b48..0000000000 --- a/tests/resources/postgresql/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -ARG POSTGRES_VERSION=17 -FROM postgres:${POSTGRES_VERSION} - -ARG POSTGRES_VERSION=17 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - postgresql-${POSTGRES_VERSION}-postgis-3 \ - postgresql-${POSTGRES_VERSION}-postgis-3-scripts \ - postgresql-${POSTGRES_VERSION}-pgvector \ - && rm -rf /var/lib/apt/lists/* diff --git a/tests/unit/Event/MockPublisher.php b/tests/unit/Event/MockPublisher.php index 0b812e7032..a7118d3c09 100644 --- a/tests/unit/Event/MockPublisher.php +++ b/tests/unit/Event/MockPublisher.php @@ -23,7 +23,7 @@ class MockPublisher implements Publisher return $this->events[$queue] ?? null; } - public function retry(Queue $queue, int $limit = null): void + public function retry(Queue $queue, ?int $limit = null): void { // TODO: Implement retry() method. } diff --git a/tests/unit/Messaging/MessagingChannelsTest.php b/tests/unit/Messaging/MessagingChannelsTest.php index 598a47a901..fc2d839ca6 100644 --- a/tests/unit/Messaging/MessagingChannelsTest.php +++ b/tests/unit/Messaging/MessagingChannelsTest.php @@ -16,7 +16,7 @@ class MessagingChannelsTest extends TestCase */ public $connectionsPerChannel = 10; - public Realtime $realtime; + public ?Realtime $realtime = null; public $connectionsCount = 0; public $connectionsAuthenticated = 0; public $connectionsGuest = 0; @@ -125,7 +125,7 @@ class MessagingChannelsTest extends TestCase public function tearDown(): void { - unset($this->realtime); + $this->realtime = null; $this->connectionsCount = 0; } diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php index 8df452d8de..507a4e25f6 100644 --- a/tests/unit/Platform/Modules/Installer/ModuleTest.php +++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Platform\Modules\Installer; use Appwrite\Platform\Installer\Http\Installer\Complete; use Appwrite\Platform\Installer\Http\Installer\Error; use Appwrite\Platform\Installer\Http\Installer\Install; +use Appwrite\Platform\Installer\Http\Installer\Reset; use Appwrite\Platform\Installer\Http\Installer\Shutdown; use Appwrite\Platform\Installer\Http\Installer\Status; use Appwrite\Platform\Installer\Http\Installer\Validate; @@ -41,13 +42,15 @@ class ModuleTest extends TestCase $service = reset($services); $actions = $service->getActions(); - $this->assertCount(6, $actions); + $this->assertCount(8, $actions); $this->assertArrayHasKey('installerView', $actions); $this->assertArrayHasKey('installerStatus', $actions); $this->assertArrayHasKey('installerValidate', $actions); $this->assertArrayHasKey('installerComplete', $actions); $this->assertArrayHasKey('installerShutdown', $actions); + $this->assertArrayHasKey('installerReset', $actions); $this->assertArrayHasKey('installerInstall', $actions); + $this->assertArrayHasKey('installerCertificateGet', $actions); } public function testViewAction(): void @@ -108,6 +111,18 @@ class ModuleTest extends TestCase $this->assertActionInjects($action, ['request', 'response', 'swooleServer']); } + public function testResetAction(): void + { + $action = $this->getAction('installerReset'); + + $this->assertEquals('installerReset', Reset::getName()); + $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod()); + $this->assertEquals('/install/reset', $action->getHttpPath()); + $this->assertEquals(Action::TYPE_DEFAULT, $action->getType()); + $this->assertActionParams($action, ['installId', 'hard']); + $this->assertActionInjects($action, ['request', 'response', 'installerState', 'installerConfig']); + } + public function testInstallAction(): void { $action = $this->getAction('installerInstall'); @@ -119,7 +134,7 @@ class ModuleTest extends TestCase $this->assertActionParams($action, [ 'appDomain', 'httpPort', 'httpsPort', 'emailCertificates', 'opensslKey', 'assistantOpenAIKey', 'accountEmail', 'accountPassword', 'database', - 'installId', 'retryStep', + 'installId', 'retryStep', 'migrate', ]); $this->assertActionInjects($action, ['request', 'response', 'swooleResponse', 'installerState', 'installerConfig', 'installerPaths']); } @@ -206,6 +221,7 @@ class ModuleTest extends TestCase $this->assertEquals('installerValidate', Validate::getName()); $this->assertEquals('installerComplete', Complete::getName()); $this->assertEquals('installerShutdown', Shutdown::getName()); + $this->assertEquals('installerReset', Reset::getName()); $this->assertEquals('installerInstall', Install::getName()); $this->assertEquals('installerError', Error::getName()); } @@ -221,6 +237,7 @@ class ModuleTest extends TestCase $this->assertInstanceOf(Validate::class, $actions['installerValidate']); $this->assertInstanceOf(Complete::class, $actions['installerComplete']); $this->assertInstanceOf(Shutdown::class, $actions['installerShutdown']); + $this->assertInstanceOf(Reset::class, $actions['installerReset']); $this->assertInstanceOf(Install::class, $actions['installerInstall']); } @@ -239,7 +256,7 @@ class ModuleTest extends TestCase public function testPostRoutesUsePostMethod(): void { - $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerInstall']; + $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerReset', 'installerInstall']; foreach ($postActions as $name) { $action = $this->getAction($name); $this->assertEquals( diff --git a/tests/unit/Utopia/Database/Documents/UserTest.php b/tests/unit/Utopia/Database/Documents/UserTest.php index 4094b43246..b3638e7d3a 100644 --- a/tests/unit/Utopia/Database/Documents/UserTest.php +++ b/tests/unit/Utopia/Database/Documents/UserTest.php @@ -171,36 +171,40 @@ class UserTest extends TestCase public function testIsPrivilegedUser(): void { - $this->assertEquals(false, User::isPrivileged([])); - $this->assertEquals(false, User::isPrivileged([Role::guests()->toString()])); - $this->assertEquals(false, User::isPrivileged([Role::users()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_ADMIN])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_DEVELOPER])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_SYSTEM])); + $user = new User(); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(false, User::isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(true, User::isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isPrivileged([])); + $this->assertEquals(false, $user->isPrivileged([Role::guests()->toString()])); + $this->assertEquals(false, $user->isPrivileged([Role::users()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_ADMIN])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_DEVELOPER])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_SYSTEM])); + + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(false, $user->isPrivileged([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(true, $user->isPrivileged([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } public function testIsAppUser(): void { - $this->assertEquals(false, User::isApp([])); - $this->assertEquals(false, User::isApp([Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([Role::users()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_ADMIN])); - $this->assertEquals(false, User::isApp([User::ROLE_DEVELOPER])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER])); - $this->assertEquals(true, User::isApp([User::ROLE_APPS])); - $this->assertEquals(false, User::isApp([User::ROLE_SYSTEM])); + $user = new User(); - $this->assertEquals(true, User::isApp([User::ROLE_APPS, User::ROLE_APPS])); - $this->assertEquals(true, User::isApp([User::ROLE_APPS, Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER, Role::guests()->toString()])); - $this->assertEquals(false, User::isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isApp([])); + $this->assertEquals(false, $user->isApp([Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([Role::users()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_ADMIN])); + $this->assertEquals(false, $user->isApp([User::ROLE_DEVELOPER])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER])); + $this->assertEquals(true, $user->isApp([User::ROLE_APPS])); + $this->assertEquals(false, $user->isApp([User::ROLE_SYSTEM])); + + $this->assertEquals(true, $user->isApp([User::ROLE_APPS, User::ROLE_APPS])); + $this->assertEquals(true, $user->isApp([User::ROLE_APPS, Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, Role::guests()->toString()])); + $this->assertEquals(false, $user->isApp([User::ROLE_OWNER, User::ROLE_ADMIN, User::ROLE_DEVELOPER])); } public function testGuestRoles(): void diff --git a/tests/unit/Utopia/Response/Filters/V16Test.php b/tests/unit/Utopia/Response/Filters/V16Test.php index e771146e3a..2ba60d35e9 100644 --- a/tests/unit/Utopia/Response/Filters/V16Test.php +++ b/tests/unit/Utopia/Response/Filters/V16Test.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V16; use Cron\CronExpression; use PHPUnit\Framework\Attributes\DataProvider; @@ -11,10 +12,7 @@ use Utopia\Database\DateTime; class V16Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V17Test.php b/tests/unit/Utopia/Response/Filters/V17Test.php index 21d91e1314..0bdc4de53e 100644 --- a/tests/unit/Utopia/Response/Filters/V17Test.php +++ b/tests/unit/Utopia/Response/Filters/V17Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V17; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V17Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V18Test.php b/tests/unit/Utopia/Response/Filters/V18Test.php index da169a7d0e..2e09b34515 100644 --- a/tests/unit/Utopia/Response/Filters/V18Test.php +++ b/tests/unit/Utopia/Response/Filters/V18Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V18; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V18Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void { diff --git a/tests/unit/Utopia/Response/Filters/V19Test.php b/tests/unit/Utopia/Response/Filters/V19Test.php index eaeefba2fc..a53dbfe355 100644 --- a/tests/unit/Utopia/Response/Filters/V19Test.php +++ b/tests/unit/Utopia/Response/Filters/V19Test.php @@ -3,16 +3,14 @@ namespace Tests\Unit\Utopia\Response\Filters; use Appwrite\Utopia\Response; +use Appwrite\Utopia\Response\Filter; use Appwrite\Utopia\Response\Filters\V19; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class V19Test extends TestCase { - /** - * @var Filter - */ - protected $filter = null; + protected Filter $filter; public function setUp(): void {