diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 970fc22de..f82d92b3d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,40 +1,100 @@ name: "Tests" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE: databases-dev + CACHE_KEY: databases-dev-${{ github.event.pull_request.head.sha }} + on: [pull_request] + jobs: - tests: - name: Unit & E2E + setup: + name: Setup & Build Docker Image + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build Docker Image + uses: docker/build-push-action@v3 + with: + context: . + push: false + tags: ${{ env.IMAGE }} + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=docker,dest=/tmp/${{ env.IMAGE }}.tar + + - name: Cache Docker Image + uses: actions/cache@v3 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + + unit_test: + name: Unit Test runs-on: ubuntu-latest + needs: setup steps: - - name: Checkout repository - uses: actions/checkout@v3 - with: - fetch-depth: 2 - submodules: recursive - - - run: git checkout HEAD^2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build image - uses: docker/build-push-action@v3 - with: - context: . - push: false - tags: database-dev - load: true - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Start Databases - run: | - docker compose up -d - sleep 10 - - - name: Run Tests - run: docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml - - - name: Check Coverage - run: docker compose exec -T tests vendor/bin/coverage-check tmp/clover.xml 90 \ No newline at end of file + - name: checkout + uses: actions/checkout@v2 + + - name: Load Cache + uses: actions/cache@v3 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Load and Start Services + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose up -d + sleep 10 + + - name: Run Unit Tests + run: docker compose exec tests vendor/bin/phpunit /usr/src/code/tests/unit + + adapter_test: + name: Adapter Tests + runs-on: ubuntu-latest + needs: setup + strategy: + fail-fast: false + matrix: + adapter: + [ + MariaDB, + MySQL, + Postgres, + SQLite, + MongoDB, + ] + + steps: + - name: checkout + uses: actions/checkout@v3 + + - name: Load Cache + uses: actions/cache@v3 + with: + key: ${{ env.CACHE_KEY }} + path: /tmp/${{ env.IMAGE }}.tar + fail-on-cache-miss: true + + - name: Load and Start Services + run: | + docker load --input /tmp/${{ env.IMAGE }}.tar + docker compose up -d + sleep 10 + + - name: Run ${{matrix.service}} Tests + run: docker compose exec -T tests vendor/bin/phpunit /usr/src/code/tests/e2e/Adapter/${{matrix.adapter}}Test.php --debug \ No newline at end of file diff --git a/README.md b/README.md index f9792d101..1adf58143 100644 --- a/README.md +++ b/README.md @@ -244,10 +244,10 @@ $database->setNamespace( ); // Get default database -$database->getDefaultDatabase(); +$database->getDatabase(); // Sets default database -$database->setDefaultDatabase( +$database->setDatabase( name: 'dbName' ); @@ -402,7 +402,7 @@ $database->deleteCollection( ); // Delete cached documents of a collection -$database->deleteCachedCollection( +$database->purgeCachedCollection( collection: 'users' ); ``` @@ -836,7 +836,7 @@ $database->deleteDocument( // Delete a cached document Note: Cached Documents or Collections are automatically deleted when a document or collection is updated or deleted -$database->deleteCachedDocument( +$database->purgeCachedDocument( collection: 'movies', id: $document->getId() ); diff --git a/bin/tasks/index.php b/bin/tasks/index.php index 66c919704..2ef8c4ef3 100644 --- a/bin/tasks/index.php +++ b/bin/tasks/index.php @@ -71,7 +71,7 @@ return; } - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); Console::info("For query: greaterThan(created, 2010-01-01 05:00:00)', 'equal(genre,travel)"); diff --git a/bin/tasks/load.php b/bin/tasks/load.php index 9362b100d..4658eadd6 100644 --- a/bin/tasks/load.php +++ b/bin/tasks/load.php @@ -56,7 +56,7 @@ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); $database = new Database(new MariaDB($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Outline collection schema @@ -89,7 +89,7 @@ $pdo = $pool->get(); $database = new Database(new MariaDB($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Each coroutine loads 1000 documents @@ -116,7 +116,7 @@ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); $database = new Database(new MySQL($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Outline collection schema @@ -150,7 +150,7 @@ $pdo = $pool->get(); $database = new Database(new MySQL($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Each coroutine loads 1000 documents @@ -178,7 +178,7 @@ ); $database = new Database(new Mongo($client), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Outline collection schema @@ -192,7 +192,7 @@ for ($i = 0; $i < $limit / 1000; $i++) { go(function () use ($client, $faker, $name, $namespace, $cache) { $database = new Database(new Mongo($client), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); // Each coroutine loads 1000 documents @@ -227,8 +227,8 @@ function createSchema(Database $database): void { - if ($database->exists($database->getDefaultDatabase())) { - $database->delete($database->getDefaultDatabase()); + if ($database->exists($database->getDatabase())) { + $database->delete($database->getDatabase()); } $database->create(); diff --git a/bin/tasks/query.php b/bin/tasks/query.php index ab7115563..2fcd29143 100644 --- a/bin/tasks/query.php +++ b/bin/tasks/query.php @@ -45,7 +45,7 @@ ); $database = new Database(new Mongo($client), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); break; @@ -58,7 +58,7 @@ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MariaDB::getPDOAttributes()); $database = new Database(new MariaDB($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); break; @@ -71,7 +71,7 @@ $pdo = new PDO("mysql:host={$dbHost};port={$dbPort};charset=utf8mb4", $dbUser, $dbPass, MySQL::getPDOAttributes()); $database = new Database(new MySQL($pdo), $cache); - $database->setDefaultDatabase($name); + $database->setDatabase($name); $database->setNamespace($namespace); break; diff --git a/composer.json b/composer.json index eda86eba2..a94a99477 100755 --- a/composer.json +++ b/composer.json @@ -9,7 +9,10 @@ "psr-4": {"Utopia\\Database\\": "src/Database"} }, "autoload-dev": { - "psr-4": {"Utopia\\Tests\\": "tests/Database"} + "psr-4": { + "Tests\\E2E\\": "tests/e2e", + "Tests\\Unit\\": "tests/unit" + } }, "scripts": { "build": [ diff --git a/composer.lock b/composer.lock index 8f3d208b3..eae5cf37d 100644 --- a/composer.lock +++ b/composer.lock @@ -513,16 +513,16 @@ }, { "name": "laravel/pint", - "version": "v1.13.5", + "version": "v1.13.6", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "df105cf8ce7a8f0b8a9425ff45cd281a5448e423" + "reference": "3e3d2ab01c7d8b484c18e6100ecf53639c744fa7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/df105cf8ce7a8f0b8a9425ff45cd281a5448e423", - "reference": "df105cf8ce7a8f0b8a9425ff45cd281a5448e423", + "url": "https://api.github.com/repos/laravel/pint/zipball/3e3d2ab01c7d8b484c18e6100ecf53639c744fa7", + "reference": "3e3d2ab01c7d8b484c18e6100ecf53639c744fa7", "shasum": "" }, "require": { @@ -533,13 +533,13 @@ "php": "^8.1.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.34.1", - "illuminate/view": "^10.26.2", - "laravel-zero/framework": "^10.1.2", + "friendsofphp/php-cs-fixer": "^3.38.0", + "illuminate/view": "^10.30.1", + "laravel-zero/framework": "^10.3.0", "mockery/mockery": "^1.6.6", "nunomaduro/larastan": "^2.6.4", "nunomaduro/termwind": "^1.15.1", - "pestphp/pest": "^2.20.0" + "pestphp/pest": "^2.24.2" }, "bin": [ "builds/pint" @@ -575,7 +575,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2023-10-26T09:26:10+00:00" + "time": "2023-11-07T17:59:57+00:00" }, { "name": "myclabs/deep-copy", @@ -839,16 +839,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.40", + "version": "1.10.44", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d" + "reference": "bf84367c53a23f759513985c54ffe0d0c249825b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/93c84b5bf7669920d823631e39904d69b9c7dc5d", - "reference": "93c84b5bf7669920d823631e39904d69b9c7dc5d", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/bf84367c53a23f759513985c54ffe0d0c249825b", + "reference": "bf84367c53a23f759513985c54ffe0d0c249825b", "shasum": "" }, "require": { @@ -897,7 +897,7 @@ "type": "tidelift" } ], - "time": "2023-10-30T14:48:31+00:00" + "time": "2023-11-21T16:30:46+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2608,5 +2608,5 @@ "php": ">=8.0" }, "platform-dev": [], - "plugin-api-version": "2.2.0" + "plugin-api-version": "2.6.0" } diff --git a/docker-compose.yml b/docker-compose.yml index af5a18be8..8b386c771 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: tests: container_name: tests - image: database-dev + image: databases-dev build: context: . networks: diff --git a/phpunit.xml b/phpunit.xml index 31b947dd6..ccdaa969e 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -9,8 +9,11 @@ processIsolation="false" stopOnFailure="false"> - - ./tests/ + + ./tests/unit + + + ./tests/e2e/Adapter diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 03bbb789e..764f41df6 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -7,15 +7,13 @@ abstract class Adapter { - /** - * @var string - */ + protected string $database = ''; + protected string $namespace = ''; - /** - * @var string - */ - protected string $defaultDatabase = ''; + protected bool $shareTables = false; + + protected ?int $tenant = null; /** * @var array @@ -73,15 +71,11 @@ public function resetDebug(): self * @param string $namespace * * @return bool - * @throws Exception + * @throws DatabaseException * */ public function setNamespace(string $namespace): bool { - if (empty($namespace)) { - throw new DatabaseException('Missing namespace'); - } - $this->namespace = $this->filter($namespace); return true; @@ -93,15 +87,10 @@ public function setNamespace(string $namespace): bool * Get namespace of current set scope * * @return string - * @throws DatabaseException * */ public function getNamespace(): string { - if (empty($this->namespace)) { - throw new DatabaseException('Missing namespace'); - } - return $this->namespace; } @@ -111,18 +100,13 @@ public function getNamespace(): string * Set database to use for current scope * * @param string $name - * @param bool $reset * * @return bool - * @throws Exception + * @throws DatabaseException */ - public function setDefaultDatabase(string $name, bool $reset = false): bool + public function setDatabase(string $name): bool { - if (empty($name) && $reset === false) { - throw new DatabaseException('Missing database'); - } - - $this->defaultDatabase = ($reset) ? '' : $this->filter($name); + $this->database = $this->filter($name); return true; } @@ -133,16 +117,72 @@ public function setDefaultDatabase(string $name, bool $reset = false): bool * Get Database from current scope * * @return string - * @throws Exception + * @throws DatabaseException * */ - public function getDefaultDatabase(): string + public function getDatabase(): string { - if (empty($this->defaultDatabase)) { - throw new DatabaseException('Missing default database'); + if (empty($this->database)) { + throw new DatabaseException('Missing database. Database must be set before use.'); } - return $this->defaultDatabase; + return $this->database; + } + + /** + * Set Share Tables. + * + * Set whether to share tables between tenants + * + * @param bool $shareTables + * + * @return bool + */ + public function setShareTables(bool $shareTables): bool + { + $this->shareTables = $shareTables; + + return true; + } + + /** + * Get Share Tables. + * + * Get whether to share tables between tenants + * + * @return bool + */ + public function getShareTables(): bool + { + return $this->shareTables; + } + + /** + * Set Tenant. + * + * Set tenant to use if tables are shared + * + * @param ?int $tenant + * + * @return bool + */ + public function setTenant(?int $tenant): bool + { + $this->tenant = $tenant; + + return true; + } + + /** + * Get Tenant. + * + * Get tenant to use for shared tables + * + * @return ?int + */ + public function getTenant(): ?int + { + return $this->tenant; } /** @@ -709,11 +749,11 @@ protected function getAttributeSelections(array $queries): array * * @param string $value * @return string - * @throws Exception + * @throws DatabaseException */ public function filter(string $value): string { - $value = preg_replace("/[^A-Za-z0-9\_\-]/", '', $value); + $value = \preg_replace("/[^A-Za-z0-9\_\-]/", '', $value); if (\is_null($value)) { throw new DatabaseException('Failed to filter key'); diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 4ee36bafe..35e6b2103 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -73,8 +73,6 @@ public function delete(string $name): bool */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { - $database = $this->getDefaultDatabase(); - $namespace = $this->getNamespace(); $id = $this->filter($name); /** @var array $attributeStrings */ @@ -116,21 +114,35 @@ public function createCollection(string $name, array $attributes = [], array $in } $sql = " - CREATE TABLE IF NOT EXISTS `{$database}`.`{$namespace}_{$id}` ( - `_id` int(11) unsigned NOT NULL AUTO_INCREMENT, - `_uid` VARCHAR(255) NOT NULL, - `_createdAt` datetime(3) DEFAULT NULL, - `_updatedAt` datetime(3) DEFAULT NULL, - `_permissions` MEDIUMTEXT DEFAULT NULL, + CREATE TABLE IF NOT EXISTS {$this->getSQLTable($id)} ( + _id INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, + _uid VARCHAR(255) NOT NULL, + _tenant INT(11) UNSIGNED DEFAULT NULL, + _createdAt DATETIME(3) DEFAULT NULL, + _updatedAt DATETIME(3) DEFAULT NULL, + _permissions MEDIUMTEXT DEFAULT NULL, + PRIMARY KEY (_id), " . \implode(' ', $attributeStrings) . " - PRIMARY KEY (`_id`), " . \implode(' ', $indexStrings) . " - UNIQUE KEY `_uid` (`_uid`), - KEY `_created_at` (`_createdAt`), - KEY `_updated_at` (`_updatedAt`) - ) "; + if ($this->shareTables) { + $sql .= " + UNIQUE KEY _uid_tenant (_uid, _tenant), + KEY _created_at (_tenant, _createdAt), + KEY _updated_at (_tenant, _updatedAt), + KEY _tenant_id (_tenant, _id) + "; + } else { + $sql .= " + UNIQUE KEY _uid (_uid), + KEY _created_at (_createdAt), + KEY _updated_at (_updatedAt) + "; + } + + $sql .= ")"; + $sql = $this->trigger(Database::EVENT_COLLECTION_CREATE, $sql); try { @@ -139,17 +151,29 @@ public function createCollection(string $name, array $attributes = [], array $in ->execute(); $sql = " - CREATE TABLE IF NOT EXISTS `{$database}`.`{$namespace}_{$id}_perms` ( - `_id` int(11) unsigned NOT NULL AUTO_INCREMENT, - `_type` VARCHAR(12) NOT NULL, - `_permission` VARCHAR(255) NOT NULL, - `_document` VARCHAR(255) NOT NULL, - PRIMARY KEY (`_id`), - UNIQUE INDEX `_index1` (`_document`,`_type`,`_permission`), - INDEX `_permission` (`_permission`,`_type`,`_document`) - ) + CREATE TABLE IF NOT EXISTS {$this->getSQLTable($id . '_perms')} ( + _id int(11) UNSIGNED NOT NULL AUTO_INCREMENT, + _tenant INT(11) UNSIGNED DEFAULT NULL, + _type VARCHAR(12) NOT NULL, + _permission VARCHAR(255) NOT NULL, + _document VARCHAR(255) NOT NULL, + PRIMARY KEY (_id), "; + if ($this->shareTables) { + $sql .= " + UNIQUE INDEX _index1 (_document, _tenant, _type, _permission), + INDEX _permission (_tenant, _permission, _type) + "; + } else { + $sql .= " + UNIQUE INDEX _index1 (_document, _type, _permission), + INDEX _permission (_permission, _type) + "; + } + + $sql .= ")"; + $sql = $this->trigger(Database::EVENT_COLLECTION_CREATE, $sql); $this->getPDO() @@ -166,7 +190,8 @@ public function createCollection(string $name, array $attributes = [], array $in } /** - * Get Collection Size + * Get collection size + * * @param string $collection * @return int * @throws DatabaseException @@ -175,7 +200,7 @@ public function getSizeOfCollection(string $collection): int { $collection = $this->filter($collection); $collection = $this->getNamespace() . '_' . $collection; - $database = $this->getDefaultDatabase(); + $database = $this->getDatabase(); $name = $database . '/' . $collection; $permissions = $database . '/' . $collection . '_perms'; @@ -206,7 +231,8 @@ public function getSizeOfCollection(string $collection): int } /** - * Delete Collection + * Delete collection + * * @param string $id * @return bool * @throws Exception @@ -345,7 +371,7 @@ public function renameAttribute(string $collection, string $old, string $new): b * @param bool $twoWay * @param string $twoWayKey * @return bool - * @throws Exception + * @throws DatabaseException */ public function createRelationship( string $collection, @@ -475,6 +501,17 @@ public function updateRelationship( ->execute(); } + /** + * @param string $collection + * @param string $relatedCollection + * @param string $type + * @param bool $twoWay + * @param string $key + * @param string $twoWayKey + * @param string $side + * @return bool + * @throws DatabaseException + */ public function deleteRelationship( string $collection, string $relatedCollection, @@ -651,9 +688,10 @@ public function deleteIndex(string $collection, string $id): bool public function createDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + $attributes['_permissions'] = \json_encode($document->getPermissions()); $name = $this->filter($collection); $columns = ''; @@ -663,7 +701,7 @@ public function createDocument(string $collection, Document $document): Document * Insert Attributes */ $bindIndex = 0; - foreach ($attributes as $attribute => $value) { // Parse statement + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); $bindKey = 'key_' . $bindIndex; $columns .= "`{$column}`, "; @@ -671,7 +709,7 @@ public function createDocument(string $collection, Document $document): Document $bindIndex++; } - // Insert manual id if set + // Insert internal ID if set if (!empty($document->getInternalId())) { $bindKey = '_id'; $columns .= "_id, "; @@ -679,7 +717,7 @@ public function createDocument(string $collection, Document $document): Document } $sql = " - INSERT INTO {$this->getSQLTable($name)}({$columns} _uid) + INSERT INTO {$this->getSQLTable($name)} ({$columns} _uid) VALUES ({$columnNames} :_uid) "; @@ -687,21 +725,19 @@ public function createDocument(string $collection, Document $document): Document $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); + $stmt->bindValue(':_uid', $document->getId()); - // Bind manual internal id if set if (!empty($document->getInternalId())) { - $stmt->bindValue(':_id', $document->getInternalId(), PDO::PARAM_STR); + $stmt->bindValue(':_id', $document->getInternalId()); } $attributeIndex = 0; - foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings + foreach ($attributes as $value) { + if (is_array($value)) { $value = json_encode($value); } $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); $value = (is_bool($value)) ? (int)$value : $value; $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); $attributeIndex++; @@ -711,19 +747,20 @@ public function createDocument(string $collection, Document $document): Document foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $permission) { $permission = \str_replace('"', '', $permission); - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}', :_tenant)"; } } if (!empty($permissions)) { - $strPermissions = \implode(', ', $permissions); + $permissions = \implode(', ', $permissions); $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document) - VALUES {$strPermissions} + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document, _tenant) + VALUES {$permissions} "; $sqlPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sqlPermissions); $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); + $stmtPermissions->bindValue(':_tenant', $this->tenant); } try { @@ -787,6 +824,7 @@ public function createDocuments(string $collection, array $documents, int $batch foreach ($batch as $document) { $attributes = $document->getAttributes(); $attributes['_uid'] = $document->getId(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = \json_encode($document->getPermissions()); @@ -815,7 +853,7 @@ public function createDocuments(string $collection, array $documents, int $batch foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $permission) { $permission = \str_replace('"', '', $permission); - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}', :_tenant)"; } } } @@ -835,9 +873,10 @@ public function createDocuments(string $collection, array $documents, int $batch if (!empty($permissions)) { $stmtPermissions = $this->getPDO()->prepare( " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document, _tenant) VALUES " . \implode(', ', $permissions) ); + $stmtPermissions->bindValue(':_tenant', $this->tenant); $stmtPermissions?->execute(); } } @@ -874,6 +913,7 @@ public function createDocuments(string $collection, array $documents, int $batch public function updateDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -883,10 +923,14 @@ public function updateDocument(string $collection, Document $document): Document $sql = " SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} p - WHERE p._document = :_uid + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); /** @@ -894,6 +938,11 @@ public function updateDocument(string $collection, Document $document): Document */ $sqlPermissions = $this->getPDO()->prepare($sql); $sqlPermissions->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $sqlPermissions->bindValue(':_tenant', $this->tenant); + } + $sqlPermissions->execute(); $permissions = $sqlPermissions->fetchAll(); $sqlPermissions->closeCursor(); @@ -949,19 +998,27 @@ public function updateDocument(string $collection, Document $document): Document } if (!empty($removeQuery)) { $removeQuery .= ')'; - $removeQuery = " + $sql = " DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE - _document = :_uid - {$removeQuery} + WHERE _document = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $removeQuery = $sql . $removeQuery; + $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } + foreach ($removals as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); @@ -976,20 +1033,20 @@ public function updateDocument(string $collection, Document $document): Document $values = []; foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $_) { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} )"; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_tenant)"; } } $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} - (_document, _type, _permission) VALUES " . \implode(', ', $values) - ; + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _tenant) + VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(":_tenant", $this->tenant); foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); @@ -1000,7 +1057,6 @@ public function updateDocument(string $collection, Document $document): Document /** * Update Attributes */ - $bindIndex = 0; foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); @@ -1015,20 +1071,27 @@ public function updateDocument(string $collection, Document $document): Document WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $attributeIndex = 0; foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings + if (is_array($value)) { $value = json_encode($value); } $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); $value = (is_bool($value)) ? (int)$value : $value; $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); $attributeIndex++; @@ -1102,6 +1165,7 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($batch as $index => $document) { $attributes = $document->getAttributes(); $attributes['_uid'] = $document->getId(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -1126,14 +1190,27 @@ public function updateDocuments(string $collection, array $documents, int $batch $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; // Permissions logic - $permissionsStmt = $this->getPDO()->prepare(" + $sql = " SELECT _type, _permission FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid - "); + "; + + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + + $permissionsStmt = $this->getPDO()->prepare($sql); $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + $permissions = $permissionsStmt->fetchAll(); $initial = []; foreach (Database::PERMISSIONS as $type) { @@ -1161,10 +1238,16 @@ public function updateDocuments(string $collection, array $documents, int $batch $removeBindKeys[] = ':uid_' . $index; $removeBindValues[$bindKey] = $document->getId(); + $tenantQuery = ''; + if ($this->shareTables) { + $tenantQuery = ' AND _tenant = :_tenant'; + } + $removeQuery .= "( _document = :uid_{$index} + {$tenantQuery} AND _type = '{$type}' - AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { + AND _permission IN (" . \implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; $removeBindKeys[] = ':' . $bindKey; $removeBindValues[$bindKey] = $permissionsToRemove[$i]; @@ -1203,7 +1286,7 @@ public function updateDocuments(string $collection, array $documents, int $batch $bindKey = 'add_' . $type . '_' . $index . '_' . $i; $addBindValues[$bindKey] = $permission; - $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey}, :_tenant)"; if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { $addQuery .= ', '; @@ -1247,20 +1330,22 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($removeBindValues as $key => $value) { $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); } - + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } $stmtRemovePermissions->execute(); } if (!empty($addQuery)) { $stmtAddPermissions = $this->getPDO()->prepare(" - INSERT INTO {$this->getSQLTable($name . '_perms')} (`_document`, `_type`, `_permission`) + INSERT INTO {$this->getSQLTable($name . '_perms')} (`_document`, `_type`, `_permission`, `_tenant`) VALUES {$addQuery} "); foreach ($addBindValues as $key => $value) { $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); } - + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); $stmtAddPermissions->execute(); } } @@ -1306,18 +1391,25 @@ public function increaseDocumentAttribute(string $collection, string $id, string $sql = " UPDATE {$this->getSQLTable($name)} SET `{$attribute}` = `{$attribute}` + :val - WHERE - _uid = :_uid - {$sqlMax} - {$sqlMin} + WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql .= $sqlMax . $sqlMin; + $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $id); $stmt->bindValue(':val', $value); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $stmt->execute() || throw new DatabaseException('Failed to update attribute'); return true; } @@ -1340,22 +1432,38 @@ public function deleteDocument(string $collection, string $id): bool WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $id); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $sql = " DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); $stmtPermissions = $this->getPDO()->prepare($sql); $stmtPermissions->bindValue(':_uid', $id); + if ($this->shareTables) { + $stmtPermissions->bindValue(':_tenant', $this->tenant); + } + try { $this->getPDO()->beginTransaction(); @@ -1363,7 +1471,7 @@ public function deleteDocument(string $collection, string $id): bool throw new DatabaseException('Failed to delete document'); } if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to clean permissions'); + throw new DatabaseException('Failed to delete permissions'); } $this->getPDO()->commit(); @@ -1400,6 +1508,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $orderAttributes = \array_map(fn ($orderAttribute) => match ($orderAttribute) { '$id' => '_uid', '$internalId' => '_id', + '$tenant' => '_tenant', '$createdAt' => '_createdAt', '$updatedAt' => '_updatedAt', default => $orderAttribute @@ -1483,6 +1592,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $where[] = $this->getSQLPermissionsCondition($name, $roles); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } + $sqlWhere = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; $sqlOrder = 'ORDER BY ' . implode(', ', $orders); $sqlLimit = \is_null($limit) ? '' : 'LIMIT :limit'; @@ -1505,6 +1618,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { $attribute = $orderAttributes[0]; @@ -1512,6 +1628,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $attribute = match ($attribute) { '_uid' => '$id', '_id' => '$internalId', + '_tenant' => '$tenant', '_createdAt' => '$createdAt', '_updatedAt' => '$updatedAt', default => $attribute @@ -1548,6 +1665,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $results[$index]['$internalId'] = $document['_id']; unset($results[$index]['_id']); } + if (\array_key_exists('_tenant', $document)) { + $results[$index]['$tenant'] = $document['_tenant']; + unset($results[$index]['_tenant']); + } if (\array_key_exists('_createdAt', $document)) { $results[$index]['$createdAt'] = $document['_createdAt']; unset($results[$index]['_createdAt']); @@ -1596,6 +1717,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) $where[] = $this->getSQLPermissionsCondition($name, $roles); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } + $sqlWhere = !empty($where) ? 'WHERE ' . \implode(' AND ', $where) : ''; @@ -1604,7 +1729,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) SELECT COUNT(1) as sum FROM ( SELECT 1 FROM {$this->getSQLTable($name)} table_main - " . $sqlWhere . " + {$sqlWhere} {$limit} ) table_count "; @@ -1617,6 +1742,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + if (!\is_null($max)) { $stmt->bindValue(':max', $max, PDO::PARAM_INT); } @@ -1658,6 +1787,10 @@ public function sum(string $collection, string $attribute, array $queries = [], $where[] = $this->getSQLPermissionsCondition($name, $roles); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } + $sqlWhere = !empty($where) ? 'WHERE ' . \implode(' AND ', $where) : ''; @@ -1679,6 +1812,10 @@ public function sum(string $collection, string $attribute, array $queries = [], $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + if (!\is_null($max)) { $stmt->bindValue(':max', $max, PDO::PARAM_INT); } @@ -1755,6 +1892,7 @@ protected function getSQLCondition(Query $query): string $query->setAttribute(match ($query->getAttribute()) { '$id' => '_uid', '$internalId' => '_id', + '$tenant' => '_tenant', '$createdAt' => '_createdAt', '$updatedAt' => '_updatedAt', default => $query->getAttribute() @@ -1851,7 +1989,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true): str */ protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string { - $type = match ($type) { + $sqlType = match ($type) { Database::INDEX_KEY, Database::INDEX_ARRAY => 'INDEX', Database::INDEX_UNIQUE => 'UNIQUE INDEX', @@ -1859,7 +1997,14 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT), }; - return "CREATE {$type} `{$id}` ON {$this->getSQLTable($collection)} ( " . implode(', ', $attributes) . " )"; + $attributes = \implode(', ', $attributes); + + if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { + // Add tenant as first index column for best performance + $attributes = "_tenant, {$attributes}"; + } + + return "CREATE {$sqlType} `{$id}` ON {$this->getSQLTable($collection)} ({$attributes})"; } /** diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 451913461..2e7ba36b4 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -168,21 +168,18 @@ public function createCollection(string $name, array $attributes = [], array $in throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } - $indexesCreated = $this->client->createIndexes($id, [ - [ - 'key' => ['_uid' => $this->getOrder(Database::ORDER_DESC)], - 'name' => '_uid', - 'unique' => true, - 'collation' => [ // https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index - 'locale' => 'en', - 'strength' => 1, - ] - ], - [ - 'key' => ['_permissions' => $this->getOrder(Database::ORDER_DESC)], - 'name' => '_permissions', + $indexesCreated = $this->client->createIndexes($id, [[ + 'key' => ['_uid' => $this->getOrder(Database::ORDER_DESC)], + 'name' => '_uid', + 'unique' => true, + 'collation' => [ // https://docs.mongodb.com/manual/core/index-case-insensitive/#create-a-case-insensitive-index + 'locale' => 'en', + 'strength' => 1, ] - ]); + ], [ + 'key' => ['_permissions' => $this->getOrder(Database::ORDER_DESC)], + 'name' => '_permissions', + ]]); if (!$indexesCreated) { return false; @@ -612,8 +609,7 @@ public function deleteIndex(string $collection, string $id): bool { $name = $this->getNamespace() . '_' . $this->filter($collection); $id = $this->filter($id); - $collection = $this->getDatabase(); - $collection->dropIndexes($name, [$id]); + $this->getClient()->dropIndexes($name, [$id]); return true; } @@ -632,6 +628,11 @@ public function getDocument(string $collection, string $id, array $queries = []) $name = $this->getNamespace() . '_' . $this->filter($collection); $filters = ['_uid' => $id]; + + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + $options = []; $selections = $this->getAttributeSelections($queries); @@ -665,7 +666,10 @@ public function createDocument(string $collection, Document $document): Document { $name = $this->getNamespace() . '_' . $this->filter($collection); $internalId = $document->getInternalId(); - $document->removeAttribute('$internalId'); + + $document + ->removeAttribute('$internalId') + ->setAttribute('$tenant', (string)$this->getTenant()); $record = $this->replaceChars('$', '_', (array)$document); $record = $this->timeToMongo($record); @@ -699,7 +703,9 @@ public function createDocuments(string $collection, array $documents, int $batch $records = []; foreach ($documents as $document) { - $document->removeAttribute('$internalId'); + $document + ->removeAttribute('$internalId') + ->setAttribute('$tenant', (string)$this->getTenant()); $record = $this->replaceChars('$', '_', (array)$document); $record = $this->timeToMongo($record); @@ -732,9 +738,15 @@ private function insertDocument(string $name, array $document): array try { $this->client->insert($name, $document); + $filters = []; + $filters['_uid'] = $document['_uid']; + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + $result = $this->client->find( $name, - ['_uid' => $document['_uid']], + $filters, ['limit' => 1] )->cursor->firstBatch[0]; @@ -761,8 +773,14 @@ public function updateDocument(string $collection, Document $document): Document $record = $this->replaceChars('$', '_', $record); $record = $this->timeToMongo($record); + $filters = []; + $filters['_uid'] = $document->getId(); + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + try { - $this->client->update($name, ['_uid' => $document->getId()], $record); + $this->client->update($name, $filters, $record); } catch (MongoException $e) { throw new Duplicate($e->getMessage()); } @@ -790,7 +808,13 @@ public function updateDocuments(string $collection, array $documents, int $batch $document = $this->replaceChars('$', '_', $document); $document = $this->timeToMongo($document); - $this->client->update($name, ['_uid' => $document['_uid']], $document); + $filters = []; + $filters['_uid'] = $document['_uid']; + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + + $this->client->update($name, $filters, $document); $documents[$index] = new Document($document); } @@ -813,19 +837,23 @@ public function updateDocuments(string $collection, array $documents, int $batch public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value, int|float|null $min = null, int|float|null $max = null): bool { $attribute = $this->filter($attribute); - $where = ['_uid' => $id]; + $filters = ['_uid' => $id]; + + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } if ($max) { - $where[$attribute] = ['$lte' => $max]; + $filters[$attribute] = ['$lte' => $max]; } if ($min) { - $where[$attribute] = ['$gte' => $min]; + $filters[$attribute] = ['$gte' => $min]; } $this->client->update( $this->getNamespace() . '_' . $this->filter($collection), - $where, + $filters, ['$inc' => [$attribute => $value]], ); @@ -845,7 +873,13 @@ public function deleteDocument(string $collection, string $id): bool { $name = $this->getNamespace() . '_' . $this->filter($collection); - $result = $this->client->delete($name, ['_uid' => $id]); + $filters = []; + $filters['_uid'] = $id; + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + + $result = $this->client->delete($name, $filters); return (!!$result); } @@ -890,6 +924,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $filters = $this->buildFilters($queries); + if ($this->shareTables) { + $filters['_tenant'] = (string)$this->getTenant(); + } + // permissions if (Authorization::$status) { // skip if authorization is disabled $roles = \implode('|', Authorization::getRoles()); @@ -1219,17 +1257,6 @@ public function sum(string $collection, string $attribute, array $queries = [], return $this->client->aggregate($name, $pipeline)->cursor->firstBatch[0]->total ?? 0; } - /** - * @param string|null $name - * @return Client - * - * @throws Exception - */ - protected function getDatabase(string $name = null): Client - { - return $this->getClient()->selectDatabase(); - } - /** * @return Client * @@ -1269,27 +1296,29 @@ protected function replaceChars(string $from, string $to, array $array): array if ($from === '_') { if (array_key_exists('_id', $array)) { $result['$internalId'] = (string)$array['_id']; - unset($result['_id']); } - if (array_key_exists('_uid', $array)) { $result['$id'] = $array['_uid']; - unset($result['_uid']); } + if (array_key_exists('_tenant', $array)) { + $result['$tenant'] = $array['_tenant']; + unset($result['_tenant']); + } } elseif ($from === '$') { if (array_key_exists('$id', $array)) { $result['_uid'] = $array['$id']; - unset($result['$id']); } - if (array_key_exists('$internalId', $array)) { $result['_id'] = new ObjectId($array['$internalId']); - unset($result['$internalId']); } + if (array_key_exists('$tenant', $array)) { + $result['_tenant'] = $array['$tenant']; + unset($result['$tenant']); + } } return $result; @@ -1430,9 +1459,14 @@ protected function getAttributeProjection(array $selections, string $prefix = '' { $projection = []; + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + Database::INTERNAL_ATTRIBUTES + ); + foreach ($selections as $selection) { // Skip internal attributes since all are selected by default - if (\in_array($selection, Database::INTERNAL_ATTRIBUTES)) { + if (\in_array($selection, $internalKeys)) { continue; } @@ -1599,7 +1633,7 @@ public function getCountOfIndexes(Document $collection): int */ public static function getCountOfDefaultAttributes(): int { - return 6; + return \count(Database::INTERNAL_ATTRIBUTES); } /** @@ -1609,7 +1643,7 @@ public static function getCountOfDefaultAttributes(): int */ public static function getCountOfDefaultIndexes(): int { - return 5; + return \count(Database::INTERNAL_INDEXES); } /** @@ -1647,58 +1681,6 @@ public function getSupportForCasting(): bool return true; } - /** - * Return set namespace. - * - * @return string - * @throws Exception - */ - public function getNamespace(): string - { - if (empty($this->namespace)) { - throw new DatabaseException('Missing namespace'); - } - - return $this->namespace; - } - - /** - * Set's default database. - * - * @param string $name - * @param bool $reset - * @return bool - * @throws Exception - */ - public function setDefaultDatabase(string $name, bool $reset = false): bool - { - if (empty($name) && $reset === false) { - throw new DatabaseException('Missing database'); - } - - $this->defaultDatabase = ($reset) ? '' : $this->filter($name); - - return true; - } - - /** - * Set's the namespace. - * - * @param string $namespace - * @return bool - * @throws Exception - */ - public function setNamespace(string $namespace): bool - { - if (empty($namespace)) { - throw new DatabaseException('Missing namespace'); - } - - $this->namespace = $this->filter($namespace); - - return true; - } - /** * Flattens the array. * @@ -1773,6 +1755,7 @@ public function setTimeout(int $milliseconds, string $event = Database::EVENT_AL if (!$this->getSupportForTimeouts()) { return; } + $this->timeout = $milliseconds; } diff --git a/src/Database/Adapter/MySQL.php b/src/Database/Adapter/MySQL.php index da6bf6dd7..23d98cadc 100644 --- a/src/Database/Adapter/MySQL.php +++ b/src/Database/Adapter/MySQL.php @@ -47,7 +47,7 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT); } - return 'CREATE '.$type.' `'.$id.'` ON `'.$this->getDefaultDatabase().'`.`'.$this->getNamespace().'_'.$collection.'` ( '.implode(', ', $attributes).' );'; + return 'CREATE '.$type.' `'.$id.'` ON `'.$this->getDatabase().'`.`'.$this->getNamespace().'_'.$collection.'` ( '.implode(', ', $attributes).' );'; } /** @@ -103,7 +103,7 @@ public function getSizeOfCollection(string $collection): int { $collection = $this->filter($collection); $collection = $this->getNamespace() . '_' . $collection; - $database = $this->getDefaultDatabase(); + $database = $this->getDatabase(); $name = $database . '/' . $collection; $permissions = $database . '/' . $collection . '_perms'; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index d065b9850..b92a780fb 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -31,6 +31,7 @@ class Postgres extends SQL * @param string $name * * @return bool + * @throws DatabaseException */ public function create(string $name): bool { @@ -78,7 +79,6 @@ public function delete(string $name): bool */ public function createCollection(string $name, array $attributes = [], array $indexes = []): bool { - $database = $this->getDefaultDatabase(); $namespace = $this->getNamespace(); $id = $this->filter($name); @@ -100,20 +100,32 @@ public function createCollection(string $name, array $attributes = [], array $in */ $sql = " CREATE TABLE IF NOT EXISTS {$this->getSQLTable($id)} ( - \"_id\" SERIAL NOT NULL, - \"_uid\" VARCHAR(255) NOT NULL, + _id SERIAL NOT NULL, + _uid VARCHAR(255) NOT NULL, + _tenant INTEGER DEFAULT NULL, \"_createdAt\" TIMESTAMP(3) DEFAULT NULL, \"_updatedAt\" TIMESTAMP(3) DEFAULT NULL, - \"_permissions\" TEXT DEFAULT NULL, + _permissions TEXT DEFAULT NULL, " . \implode(' ', $attributes) . " - PRIMARY KEY (\"_id\") + PRIMARY KEY (_id) ); - - CREATE UNIQUE INDEX \"{$namespace}_{$id}_uid\" on {$this->getSQLTable($id)} (LOWER(\"_uid\")); - CREATE INDEX \"{$namespace}_{$id}_created\" ON {$this->getSQLTable($id)} (\"_createdAt\"); - CREATE INDEX \"{$namespace}_{$id}_updated\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); "; + if ($this->shareTables) { + $sql .= " + CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_uid\" ON {$this->getSQLTable($id)} (LOWER(_uid), _tenant); + CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_created\" ON {$this->getSQLTable($id)} (_tenant, \"_createdAt\"); + CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_updated\" ON {$this->getSQLTable($id)} (_tenant, \"_updatedAt\"); + CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_tenant_id\" ON {$this->getSQLTable($id)} (_tenant, _id); + "; + } else { + $sql .= " + CREATE UNIQUE INDEX \"{$namespace}_{$id}_uid\" ON {$this->getSQLTable($id)} (LOWER(_uid)); + CREATE INDEX \"{$namespace}_{$id}_created\" ON {$this->getSQLTable($id)} (\"_createdAt\"); + CREATE INDEX \"{$namespace}_{$id}_updated\" ON {$this->getSQLTable($id)} (\"_updatedAt\"); + "; + } + $sql = $this->trigger(Database::EVENT_COLLECTION_CREATE, $sql); $stmt = $this->getPDO()->prepare($sql); @@ -123,18 +135,31 @@ public function createCollection(string $name, array $attributes = [], array $in $sql = " CREATE TABLE IF NOT EXISTS {$this->getSQLTable($id . '_perms')} ( - \"_id\" SERIAL NOT NULL, - \"_type\" VARCHAR(12) NOT NULL, - \"_permission\" VARCHAR(255) NOT NULL, - \"_document\" VARCHAR(255) NOT NULL, - PRIMARY KEY (\"_id\") - ); - CREATE UNIQUE INDEX \"index_{$namespace}_{$id}_ukey\" - ON {$this->getSQLTable($id. '_perms')} USING btree (\"_document\",\"_type\",\"_permission\"); - CREATE INDEX \"index_{$namespace}_{$id}_permission\" - ON {$this->getSQLTable($id. '_perms')} USING btree (\"_permission\",\"_type\",\"_document\"); + _id SERIAL NOT NULL, + _tenant INTEGER DEFAULT NULL, + _type VARCHAR(12) NOT NULL, + _permission VARCHAR(255) NOT NULL, + _document VARCHAR(255) NOT NULL, + PRIMARY KEY (_id) + ); "; + if ($this->shareTables) { + $sql .= " + CREATE UNIQUE INDEX \"{$namespace}_{$this->tenant}_{$id}_ukey\" + ON {$this->getSQLTable($id. '_perms')} USING btree (_tenant,_document,_type,_permission); + CREATE INDEX \"{$namespace}_{$this->tenant}_{$id}_permission\" + ON {$this->getSQLTable($id. '_perms')} USING btree (_tenant,_permission,_type); + "; + } else { + $sql .= " + CREATE UNIQUE INDEX \"{$namespace}_{$id}_ukey\" + ON {$this->getSQLTable($id. '_perms')} USING btree (_document,_type,_permission); + CREATE INDEX \"{$namespace}_{$id}_permission\" + ON {$this->getSQLTable($id. '_perms')} USING btree (_permission,_type); + "; + } + $sql = $this->trigger(Database::EVENT_COLLECTION_CREATE, $sql); $this->getPDO() @@ -608,9 +633,11 @@ public function deleteIndex(string $collection, string $id): bool { $name = $this->filter($collection); $id = $this->filter($id); - $schemaName = $this->getDefaultDatabase(); + $schemaName = $this->getDatabase(); - $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".{$id}"; + $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + + $sql = "DROP INDEX IF EXISTS \"{$schemaName}\".{$key}"; $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); return $this->getPDO() @@ -634,8 +661,8 @@ public function renameIndex(string $collection, string $old, string $new): bool $namespace = $this->getNamespace(); $old = $this->filter($old); $new = $this->filter($new); - $oldIndexName = $collection . "_" . $old; - $newIndexName = $namespace . $collection . "_" . $new; + $oldIndexName = "{$this->tenant}_{$collection}_{$old}"; + $newIndexName = "{$namespace}_{$this->tenant}_{$collection}_{$new}"; $sql = "ALTER INDEX {$this->getSQLTable($oldIndexName)} RENAME TO \"{$newIndexName}\""; $sql = $this->trigger(Database::EVENT_INDEX_RENAME, $sql); @@ -656,9 +683,10 @@ public function renameIndex(string $collection, string $old, string $new): bool public function createDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); - $attributes['_permissions'] = json_encode($document->getPermissions()); + $attributes['_permissions'] = \json_encode($document->getPermissions()); $name = $this->filter($collection); $columns = ''; @@ -670,7 +698,7 @@ public function createDocument(string $collection, Document $document): Document * Insert Attributes */ - // Insert manual id if set + // Insert internal id if set if (!empty($document->getInternalId())) { $bindKey = '_id'; $columns .= "\"_id\", "; @@ -678,7 +706,7 @@ public function createDocument(string $collection, Document $document): Document } $bindIndex = 0; - foreach ($attributes as $attribute => $value) { // Parse statement + foreach ($attributes as $attribute => $value) { $column = $this->filter($attribute); $bindKey = 'key_' . $bindIndex; $columns .= "\"{$column}\", "; @@ -687,8 +715,9 @@ public function createDocument(string $collection, Document $document): Document } $sql = " - INSERT INTO {$this->getSQLTable($name)} ({$columns}\"_uid\") - VALUES ({$columnNames}:_uid) RETURNING _id + INSERT INTO {$this->getSQLTable($name)} ({$columns} \"_uid\") + VALUES ({$columnNames} :_uid) + RETURNING _id "; $sql = $this->trigger(Database::EVENT_DOCUMENT_CREATE, $sql); @@ -697,19 +726,17 @@ public function createDocument(string $collection, Document $document): Document $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); - // Bind manual internal id if set if (!empty($document->getInternalId())) { $stmt->bindValue(':_id', $document->getInternalId(), PDO::PARAM_STR); } $attributeIndex = 0; - foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings - $value = json_encode($value); + foreach ($attributes as $value) { + if (is_array($value)) { + $value = \json_encode($value); } $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); $value = (is_bool($value)) ? ($value ? "true" : "false") : $value; $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); $attributeIndex++; @@ -719,19 +746,22 @@ public function createDocument(string $collection, Document $document): Document foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $permission) { $permission = \str_replace('"', '', $permission); - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}', :_tenant)"; } } if (!empty($permissions)) { + $permissions = \implode(', ', $permissions); + $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} - (_type, _permission, _document) VALUES " . implode(', ', $permissions); + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document, _tenant) + VALUES {$permissions} + "; $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); - $stmtPermissions = $this->getPDO()->prepare($queryPermissions); + $stmtPermissions->bindValue(':_tenant', $this->tenant); } try { @@ -747,7 +777,6 @@ public function createDocument(string $collection, Document $document): Document case 23505: $this->getPDO()->rollBack(); throw new Duplicate('Duplicated document: ' . $e->getMessage()); - default: throw $e; } @@ -791,6 +820,7 @@ public function createDocuments(string $collection, array $documents, int $batch foreach ($batch as $document) { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_uid'] = $document->getId(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -820,7 +850,7 @@ public function createDocuments(string $collection, array $documents, int $batch foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $permission) { $permission = \str_replace('"', '', $permission); - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}', :_tenant)"; } } } @@ -840,15 +870,16 @@ public function createDocuments(string $collection, array $documents, int $batch if (!empty($permissions)) { $stmtPermissions = $this->getPDO()->prepare( " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document, _tenant) VALUES " . \implode(', ', $permissions) ); + $stmtPermissions->bindValue(':_tenant', $this->tenant); $stmtPermissions?->execute(); } } if (!$this->getPDO()->commit()) { - throw new Exception('Failed to commit transaction'); + throw new DatabaseException('Failed to commit transaction'); } return $documents; @@ -874,6 +905,7 @@ public function createDocuments(string $collection, array $documents, int $batch public function updateDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -883,10 +915,14 @@ public function updateDocument(string $collection, Document $document): Document $sql = " SELECT _type, _permission - FROM {$this->getSQLTable($name . '_perms')} p - WHERE p._document = :_uid + FROM {$this->getSQLTable($name . '_perms')} + WHERE _document = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); /** @@ -894,6 +930,11 @@ public function updateDocument(string $collection, Document $document): Document */ $permissionsStmt = $this->getPDO()->prepare($sql); $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + $permissionsStmt->execute(); $permissions = $permissionsStmt->fetchAll(); $permissionsStmt->closeCursor(); @@ -951,17 +992,27 @@ public function updateDocument(string $collection, Document $document): Document } if (!empty($removeQuery)) { $removeQuery .= ')'; - $removeQuery = " + + $sql = " DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE - _document = :_uid - {$removeQuery} + WHERE _document = :_uid "; + + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $removeQuery = $sql . $removeQuery; + $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } + foreach ($removals as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); @@ -976,19 +1027,20 @@ public function updateDocument(string $collection, Document $document): Document $values = []; foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $_) { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} )"; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_tenant)"; } } $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} - (_document, _type, _permission) VALUES" . \implode(', ', $values); + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _tenant) + VALUES" . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); - $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); + foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); @@ -1010,23 +1062,31 @@ public function updateDocument(string $collection, Document $document): Document $sql = " UPDATE {$this->getSQLTable($name)} - SET {$columns} _uid = :_uid WHERE _uid = :_uid + SET {$columns} _uid = :_uid + WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $attributeIndex = 0; foreach ($attributes as $attribute => $value) { - if (is_array($value)) { // arrays & objects should be saved as strings + if (is_array($value)) { $value = json_encode($value); } $bindKey = 'key_' . $attributeIndex; - $attribute = $this->filter($attribute); $value = (is_bool($value)) ? ($value == true ? "true" : "false") : $value; $stmt->bindValue(':' . $bindKey, $value, $this->getPDOType($value)); $attributeIndex++; @@ -1096,6 +1156,7 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($batch as $index => $document) { $attributes = $document->getAttributes(); $attributes['_uid'] = $document->getId(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -1120,14 +1181,27 @@ public function updateDocuments(string $collection, array $documents, int $batch $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; // Permissions logic - $permissionsStmt = $this->getPDO()->prepare(" + $sql = " SELECT _type, _permission FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid - "); + "; + + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + + $permissionsStmt = $this->getPDO()->prepare($sql); $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + $permissions = $permissionsStmt->fetchAll(); $initial = []; foreach (Database::PERMISSIONS as $type) { @@ -1155,8 +1229,14 @@ public function updateDocuments(string $collection, array $documents, int $batch $removeBindKeys[] = ':uid_' . $index; $removeBindValues[$bindKey] = $document->getId(); + $tenantQuery = ''; + if ($this->shareTables) { + $tenantQuery = ' AND _tenant = :_tenant'; + } + $removeQuery .= "( _document = :uid_{$index} + {$tenantQuery} AND _type = '{$type}' AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; @@ -1197,7 +1277,7 @@ public function updateDocuments(string $collection, array $documents, int $batch $bindKey = 'add_' . $type . '_' . $index . '_' . $i; $addBindValues[$bindKey] = $permission; - $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey}, :_tenant)"; if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { $addQuery .= ', '; @@ -1219,11 +1299,18 @@ public function updateDocuments(string $collection, array $documents, int $batch $updateClause .= "{$column} = excluded.{$column}"; } - $stmt = $this->getPDO()->prepare(" + $sql = " INSERT INTO {$this->getSQLTable($name)} (" . \implode(", ", $columns) . ") VALUES " . \implode(', ', $batchKeys) . " - ON CONFLICT (LOWER(_uid)) DO UPDATE SET $updateClause - "); + "; + + if ($this->shareTables) { + $sql .= "ON CONFLICT (_tenant, LOWER(_uid)) DO UPDATE SET $updateClause"; + } else { + $sql .= "ON CONFLICT (LOWER(_uid)) DO UPDATE SET $updateClause"; + } + + $stmt = $this->getPDO()->prepare($sql); foreach ($bindValues as $key => $value) { $stmt->bindValue($key, $value, $this->getPDOType($value)); @@ -1241,26 +1328,29 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($removeBindValues as $key => $value) { $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); } + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } $stmtRemovePermissions->execute(); } if (!empty($addQuery)) { $stmtAddPermissions = $this->getPDO()->prepare(" - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _tenant) VALUES {$addQuery} "); foreach ($addBindValues as $key => $value) { $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); } - + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); $stmtAddPermissions->execute(); } } if (!$this->getPDO()->commit()) { - throw new Exception('Failed to commit transaction'); + throw new DatabaseException('Failed to commit transaction'); } return $documents; @@ -1297,18 +1387,25 @@ public function increaseDocumentAttribute(string $collection, string $id, string $sql = " UPDATE {$this->getSQLTable($name)} SET \"{$attribute}\" = \"{$attribute}\" + :val - WHERE - _uid = :_uid - {$sqlMax} - {$sqlMin} + WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + + $sql .= $sqlMax . $sqlMin; + $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $id); $stmt->bindValue(':val', $value); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $stmt->execute() || throw new DatabaseException('Failed to update attribute'); return true; } @@ -1332,28 +1429,43 @@ public function deleteDocument(string $collection, string $id): bool WHERE _uid = :_uid "; - $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql); + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_DELETE, $sql); $stmt = $this->getPDO()->prepare($sql); - $stmt->bindValue(':_uid', $id, PDO::PARAM_STR); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $sql = " DELETE FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid "; + + if ($this->shareTables) { + $sql .= ' AND _tenant = :_tenant'; + } + $sql = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $sql); $stmtPermissions = $this->getPDO()->prepare($sql); $stmtPermissions->bindValue(':_uid', $id); + if ($this->shareTables) { + $stmtPermissions->bindValue(':_tenant', $this->tenant); + } + try { if (!$stmt->execute()) { throw new DatabaseException('Failed to delete document'); } if (!$stmtPermissions->execute()) { - throw new DatabaseException('Failed to clean permissions'); + throw new DatabaseException('Failed to delete permissions'); } } catch (\Throwable $th) { $this->getPDO()->rollBack(); @@ -1396,6 +1508,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $orderAttributes = \array_map(fn ($orderAttribute) => match ($orderAttribute) { '$id' => '_uid', '$internalId' => '_id', + '$tenant' => '_tenant', '$createdAt' => '_createdAt', '$updatedAt' => '_updatedAt', default => $orderAttribute @@ -1468,6 +1581,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $where[] = $this->getSQLCondition($query); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } if (Authorization::$status) { $where[] = $this->getSQLPermissionsCondition($name, $roles); @@ -1494,6 +1610,9 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } if (!empty($cursor) && !empty($orderAttributes) && array_key_exists(0, $orderAttributes)) { $attribute = $orderAttributes[0]; @@ -1501,6 +1620,7 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $attribute = match ($attribute) { '_uid' => '$id', '_id' => '$internalId', + '_tenant' => '$tenant', '_createdAt' => '$createdAt', '_updatedAt' => '$updatedAt', default => $attribute @@ -1537,6 +1657,10 @@ public function find(string $collection, array $queries = [], ?int $limit = 25, $results[$index]['$internalId'] = $document['_id']; unset($results[$index]['_id']); } + if (\array_key_exists('_tenant', $document)) { + $results[$index]['$tenant'] = $document['_tenant']; + unset($results[$index]['_tenant']); + } if (\array_key_exists('_createdAt', $document)) { $results[$index]['$createdAt'] = $document['_createdAt']; unset($results[$index]['_createdAt']); @@ -1582,6 +1706,10 @@ public function count(string $collection, array $queries = [], ?int $max = null) $where[] = $this->getSQLCondition($query); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } + if (Authorization::$status) { $where[] = $this->getSQLPermissionsCondition($name, $roles); } @@ -1591,7 +1719,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) SELECT COUNT(1) as sum FROM ( SELECT 1 FROM {$this->getSQLTable($name)} table_main - " . $sqlWhere . " + {$sqlWhere} {$limit} ) table_count "; @@ -1603,6 +1731,9 @@ public function count(string $collection, array $queries = [], ?int $max = null) foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } if (!\is_null($max)) { $stmt->bindValue(':max', $max, PDO::PARAM_INT); @@ -1638,6 +1769,10 @@ public function sum(string $collection, string $attribute, array $queries = [], $where[] = $this->getSQLCondition($query); } + if ($this->shareTables) { + $where[] = "table_main._tenant = :_tenant"; + } + if (Authorization::$status) { $where[] = $this->getSQLPermissionsCondition($name, $roles); } @@ -1662,6 +1797,9 @@ public function sum(string $collection, string $attribute, array $queries = [], foreach ($queries as $query) { $this->bindConditionValue($stmt, $query); } + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } if (!\is_null($max)) { $stmt->bindValue(':max', $max, PDO::PARAM_INT); @@ -1736,6 +1874,7 @@ protected function getSQLCondition(Query $query): string $query->setAttribute(match ($query->getAttribute()) { '$id' => '_uid', '$internalId' => '_id', + '$tenant' => '_tenant', '$createdAt' => '_createdAt', '$updatedAt' => '_updatedAt', default => $query->getAttribute() @@ -1840,7 +1979,7 @@ protected function getSQLType(string $type, int $size, bool $signed = true): str */ protected function getSQLIndex(string $collection, string $id, string $type, array $attributes): string { - $type = match ($type) { + $sqlType = match ($type) { Database::INDEX_KEY, Database::INDEX_ARRAY, Database::INDEX_FULLTEXT => 'INDEX', @@ -1848,7 +1987,15 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr default => throw new DatabaseException('Unknown index type: ' . $type . '. Must be one of ' . Database::INDEX_KEY . ', ' . Database::INDEX_UNIQUE . ', ' . Database::INDEX_ARRAY . ', ' . Database::INDEX_FULLTEXT), }; - return 'CREATE ' . $type . ' "' . $this->getNamespace() . '_' . $collection . '_' . $id . '" ON ' . $this->getSQLTable($collection) . ' ( ' . implode(', ', $attributes) . ' );'; + $key = "\"{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}\""; + $attributes = \implode(', ', $attributes); + + if ($this->shareTables && $type !== Database::INDEX_FULLTEXT) { + // Add tenant as first index column for best performance + $attributes = "_tenant, {$attributes}"; + } + + return "CREATE {$sqlType} {$key} ON {$this->getSQLTable($collection)} ({$attributes});"; } /** @@ -1862,7 +2009,7 @@ protected function getSQLSchema(): string return ''; } - return "\"{$this->getDefaultDatabase()}\"."; + return "\"{$this->getDatabase()}\"."; } /** @@ -1873,7 +2020,7 @@ protected function getSQLSchema(): string */ protected function getSQLTable(string $name): string { - return "\"{$this->getDefaultDatabase()}\".\"{$this->getNamespace()}_{$name}\""; + return "\"{$this->getDatabase()}\".\"{$this->getNamespace()}_{$name}\""; } /** diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 344b74b72..4f3904f91 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -48,7 +48,7 @@ public function ping(): bool * @param string $database * @param string|null $collection * @return bool - * @throws Exception + * @throws DatabaseException */ public function exists(string $database, ?string $collection = null): bool { @@ -56,11 +56,20 @@ public function exists(string $database, ?string $collection = null): bool if (!\is_null($collection)) { $collection = $this->filter($collection); - $stmt = $this->getPDO()->prepare("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table"); + $stmt = $this->getPDO()->prepare(" + SELECT TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = :schema + AND TABLE_NAME = :table + "); $stmt->bindValue(':schema', $database, PDO::PARAM_STR); $stmt->bindValue(':table', "{$this->getNamespace()}_{$collection}", PDO::PARAM_STR); } else { - $stmt = $this->getPDO()->prepare("SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = :schema"); + $stmt = $this->getPDO()->prepare(" + SELECT SCHEMA_NAME FROM + INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME = :schema + "); $stmt->bindValue(':schema', $database, PDO::PARAM_STR); } @@ -98,16 +107,26 @@ public function list(): array public function getDocument(string $collection, string $id, array $queries = []): Document { $name = $this->filter($collection); - $selections = $this->getAttributeSelections($queries); - $stmt = $this->getPDO()->prepare(" - SELECT {$this->getAttributeProjection($selections)} + $sql = " + SELECT {$this->getAttributeProjection($selections)} FROM {$this->getSQLTable($name)} - WHERE _uid = :_uid; - "); + WHERE _uid = :_uid + "; + + if ($this->shareTables) { + $sql .= "AND _tenant = :_tenant"; + } + + $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $id); + + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->getTenant()); + } + $stmt->execute(); $document = $stmt->fetchAll(); @@ -127,6 +146,10 @@ public function getDocument(string $collection, string $id, array $queries = []) $document['$id'] = $document['_uid']; unset($document['_uid']); } + if (\array_key_exists('_tenant', $document)) { + $document['$tenant'] = $document['_tenant']; + unset($document['_tenant']); + } if (\array_key_exists('_createdAt', $document)) { $document['$createdAt'] = $document['_createdAt']; unset($document['_createdAt']); @@ -259,7 +282,7 @@ public function getCountOfIndexes(Document $collection): int */ public static function getCountOfDefaultAttributes(): int { - return 4; + return \count(Database::INTERNAL_ATTRIBUTES); } /** @@ -269,7 +292,7 @@ public static function getCountOfDefaultAttributes(): int */ public static function getCountOfDefaultIndexes(): int { - return 5; + return \count(Database::INTERNAL_INDEXES); } /** @@ -304,34 +327,20 @@ public function getAttributeWidth(Document $collection): int foreach ($attributes as $attribute) { switch ($attribute['type']) { case Database::VAR_STRING: - switch (true) { - case ($attribute['size'] > 16777215): - // 8 bytes length + 4 bytes for LONGTEXT - $total += 12; - break; - - case ($attribute['size'] > 65535): - // 8 bytes length + 3 bytes for MEDIUMTEXT - $total += 11; - break; - - case ($attribute['size'] > $this->getMaxVarcharLength()): - // 8 bytes length + 2 bytes for TEXT - $total += 10; - break; - - case ($attribute['size'] > 255): - // $size = $size * 4; // utf8mb4 up to 4 bytes per char - // 8 bytes length + 2 bytes for VARCHAR(>255) - $total += ($attribute['size'] * 4) + 2; - break; - - default: - // $size = $size * 4; // utf8mb4 up to 4 bytes per char - // 8 bytes length + 1 bytes for VARCHAR(<=255) - $total += ($attribute['size'] * 4) + 1; - break; - } + $total += match (true) { + // 8 bytes length + 4 bytes for LONGTEXT + $attribute['size'] > 16777215 => 12, + // 8 bytes length + 3 bytes for MEDIUMTEXT + $attribute['size'] > 65535 => 11, + // 8 bytes length + 2 bytes for TEXT + $attribute['size'] > $this->getMaxVarcharLength() => 10, + // $size = $size * 4; // utf8mb4 up to 4 bytes per char + // 8 bytes length + 2 bytes for VARCHAR(>255) + $attribute['size'] > 255 => ($attribute['size'] * 4) + 2, + // $size = $size * 4; // utf8mb4 up to 4 bytes per char + // 8 bytes length + 1 bytes for VARCHAR(<=255) + default => ($attribute['size'] * 4) + 1, + }; break; case Database::VAR_INTEGER: @@ -828,28 +837,21 @@ protected function getSQLIndexType(string $type): string protected function getSQLPermissionsCondition(string $collection, array $roles): string { $roles = array_map(fn (string $role) => $this->getPDO()->quote($role), $roles); + + $tenantQuery = ''; + if ($this->shareTables) { + $tenantQuery = 'AND _tenant = :_tenant'; + } + return "table_main._uid IN ( SELECT _document FROM {$this->getSQLTable($collection . '_perms')} WHERE _permission IN (" . implode(', ', $roles) . ") - AND _type = 'read' + AND _type = 'read' + {$tenantQuery} )"; } - /** - * Get SQL schema - * - * @return string - */ - protected function getSQLSchema(): string - { - if (!$this->getSupportForSchemas()) { - return ''; - } - - return "`{$this->getDefaultDatabase()}`."; - } - /** * Get SQL table * @@ -858,7 +860,7 @@ protected function getSQLSchema(): string */ protected function getSQLTable(string $name): string { - return "{$this->getSQLSchema()}`{$this->getNamespace()}_{$name}`"; + return "`{$this->getDatabase()}`.`{$this->getNamespace()}_{$name}`"; } /** diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index a11c374d5..5e9cffd61 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -120,7 +120,12 @@ public function createCollection(string $name, array $attributes = [], array $in foreach ($attributes as $key => $attribute) { $attrId = $this->filter($attribute->getId()); - $attrType = $this->getSQLType($attribute->getAttribute('type'), $attribute->getAttribute('size', 0), $attribute->getAttribute('signed', true)); + + $attrType = $this->getSQLType( + $attribute->getAttribute('type'), + $attribute->getAttribute('size', 0), + $attribute->getAttribute('signed', true) + ); if ($attribute->getAttribute('array')) { $attrType = 'LONGTEXT'; @@ -130,11 +135,12 @@ public function createCollection(string $name, array $attributes = [], array $in } $sql = " - CREATE TABLE IF NOT EXISTS `{$namespace}_{$id}` ( + CREATE TABLE IF NOT EXISTS `{$this->getSQLTable($id)}` ( `_id` INTEGER PRIMARY KEY AUTOINCREMENT, - `_uid` CHAR(255) NOT NULL, - `_createdAt` datetime(3) DEFAULT NULL, - `_updatedAt` datetime(3) DEFAULT NULL, + `_uid` VARCHAR(36) NOT NULL, + `_tenant` INTEGER DEFAULT NULL, + `_createdAt` DATETIME(3) DEFAULT NULL, + `_updatedAt` DATETIME(3) DEFAULT NULL, `_permissions` MEDIUMTEXT DEFAULT NULL".(!empty($attributes) ? ',' : '')." " . \substr(\implode(' ', $attributeStrings), 0, -2) . " ) @@ -142,15 +148,17 @@ public function createCollection(string $name, array $attributes = [], array $in $sql = $this->trigger(Database::EVENT_COLLECTION_CREATE, $sql); - $this->getPDO() - ->prepare($sql) - ->execute(); + $this->getPDO()->prepare($sql)->execute(); $this->createIndex($id, '_index1', Database::INDEX_UNIQUE, ['_uid'], [], []); - $this->createIndex($id, '_created_at', Database::INDEX_KEY, ['_createdAt'], [], []); - $this->createIndex($id, '_updated_at', Database::INDEX_KEY, ['_updatedAt'], [], []); + $this->createIndex($id, '_created_at', Database::INDEX_KEY, [ '_createdAt'], [], []); + $this->createIndex($id, '_updated_at', Database::INDEX_KEY, [ '_updatedAt'], [], []); + + if ($this->shareTables) { + $this->createIndex($id, '_tenant_id', Database::INDEX_KEY, [ '_id'], [], []); + } - foreach ($indexes as $key => $index) { + foreach ($indexes as $index) { $indexId = $this->filter($index->getId()); $indexType = $index->getAttribute('type'); $indexAttributes = $index->getAttribute('attributes', []); @@ -161,8 +169,9 @@ public function createCollection(string $name, array $attributes = [], array $in } $sql = " - CREATE TABLE IF NOT EXISTS `{$namespace}_{$id}_perms` ( + CREATE TABLE IF NOT EXISTS `{$this->getSQLTable($id)}_perms` ( `_id` INTEGER PRIMARY KEY AUTOINCREMENT, + `_tenant` INTEGER DEFAULT NULL, `_type` VARCHAR(12) NOT NULL, `_permission` VARCHAR(255) NOT NULL, `_document` VARCHAR(255) NOT NULL @@ -176,7 +185,7 @@ public function createCollection(string $name, array $attributes = [], array $in ->execute(); $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission'], [], []); - $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission'], [], []); + $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission', '_type'], [], []); $this->getPDO()->commit(); @@ -199,11 +208,15 @@ public function getSizeOfCollection(string $collection): int $permissions = $namespace . '_' . $collection . '_perms'; $collectionSize = $this->getPDO()->prepare(" - SELECT SUM(\"pgsize\") FROM \"dbstat\" WHERE name=:name; + SELECT SUM(\"pgsize\") + FROM \"dbstat\" + WHERE name = :name; "); $permissionsSize = $this->getPDO()->prepare(" - SELECT SUM(\"pgsize\") FROM \"dbstat\" WHERE name=:name; + SELECT SUM(\"pgsize\") + FROM \"dbstat\" + WHERE name = :name; "); $collectionSize->bindParam(':name', $name); @@ -237,14 +250,14 @@ public function deleteCollection(string $id): bool $this->getPDO()->rollBack(); } - $sql = "DROP TABLE `{$this->getNamespace()}_{$id}`"; + $sql = "DROP TABLE IF EXISTS `{$this->getSQLTable($id)}`"; $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); $this->getPDO() ->prepare($sql) ->execute(); - $sql = "DROP TABLE `{$this->getNamespace()}_{$id}_perms`"; + $sql = "DROP TABLE IF EXISTS `{$this->getSQLTable($id)}_perms`"; $sql = $this->trigger(Database::EVENT_COLLECTION_DELETE, $sql); $this->getPDO() @@ -379,6 +392,21 @@ public function createIndex(string $collection, string $id, string $type, array $name = $this->filter($collection); $id = $this->filter($id); + + // Workaround for no support for CREATE INDEX IF NOT EXISTS + $stmt = $this->getPDO()->prepare(" + SELECT name + FROM sqlite_master + WHERE type='index' + AND name=:_index; + "); + $stmt->bindValue(':_index', "{$this->getNamespace()}_{$this->tenant}_{$name}_{$id}"); + $stmt->execute(); + $index = $stmt->fetch(); + if (!empty($index)) { + return true; + } + $sql = $this->getSQLIndex($name, $id, $type, $attributes); $sql = $this->trigger(Database::EVENT_INDEX_CREATE, $sql); @@ -402,7 +430,7 @@ public function deleteIndex(string $collection, string $id): bool $name = $this->filter($collection); $id = $this->filter($id); - $sql = "DROP INDEX `{$this->getNamespace()}_{$name}_{$id}`"; + $sql = "DROP INDEX `{$this->getNamespace()}_{$this->tenant}_{$name}_{$id}`"; $sql = $this->trigger(Database::EVENT_INDEX_DELETE, $sql); return $this->getPDO() @@ -423,6 +451,7 @@ public function deleteIndex(string $collection, string $id): bool public function createDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -465,7 +494,7 @@ public function createDocument(string $collection, Document $document): Document $stmt->bindValue(':_uid', $document->getId(), PDO::PARAM_STR); - // Bind manual internal id if set + // Bind internal id if set if (!empty($document->getInternalId())) { $stmt->bindValue(':_id', $document->getInternalId(), PDO::PARAM_STR); } @@ -487,20 +516,19 @@ public function createDocument(string $collection, Document $document): Document foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByType($type) as $permission) { $permission = \str_replace('"', '', $permission); - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}')"; + $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}', :_tenant)"; } } if (!empty($permissions)) { $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _document) - VALUES " . \implode( - ', ', - $permissions - ); + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _document, _tenant) + VALUES " . \implode(', ', $permissions); + $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); $stmtPermissions = $this->getPDO()->prepare($queryPermissions); + $stmtPermissions->bindValue(':_tenant', $this->tenant); } try { @@ -509,6 +537,7 @@ public function createDocument(string $collection, Document $document): Document $statment = $this->getPDO()->prepare("SELECT last_insert_rowid() AS id"); $statment->execute(); $last = $statment->fetch(); + $document['$internalId'] = $last['id']; if (isset($stmtPermissions)) { @@ -542,6 +571,7 @@ public function createDocument(string $collection, Document $document): Document public function updateDocument(string $collection, Document $document): Document { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); $attributes['_permissions'] = json_encode($document->getPermissions()); @@ -556,6 +586,10 @@ public function updateDocument(string $collection, Document $document): Document WHERE _document = :_uid "; + if ($this->shareTables) { + $sql .= " AND _tenant = :_tenant"; + } + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); /** @@ -563,6 +597,11 @@ public function updateDocument(string $collection, Document $document): Document */ $permissionsStmt = $this->getPDO()->prepare($sql); $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + $permissionsStmt->execute(); $permissions = $permissionsStmt->fetchAll(); $permissionsStmt->closeCursor(); @@ -624,18 +663,26 @@ public function updateDocument(string $collection, Document $document): Document } if (!empty($removeQuery)) { $removeQuery .= ')'; - $removeQuery = " + $sql = " DELETE FROM `{$this->getNamespace()}_{$name}_perms` - WHERE - _document = :_uid - {$removeQuery} + WHERE _document = :_uid "; + + if ($this->shareTables) { + $sql .= " AND _tenant = :_tenant"; + } + + $removeQuery = $sql . $removeQuery; $removeQuery = $this->trigger(Database::EVENT_PERMISSIONS_DELETE, $removeQuery); $stmtRemovePermissions = $this->getPDO()->prepare($removeQuery); $stmtRemovePermissions->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } + foreach ($removals as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtRemovePermissions->bindValue(":_remove_{$type}_{$i}", $permission); @@ -650,19 +697,21 @@ public function updateDocument(string $collection, Document $document): Document $values = []; foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $_) { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} )"; + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_tenant)"; } } $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` - (_document, _type, _permission) VALUES " . \implode(', ', $values); + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission, _tenant) + VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); $stmtAddPermissions = $this->getPDO()->prepare($sql); $stmtAddPermissions->bindValue(":_uid", $document->getId()); + $stmtAddPermissions->bindValue(":_tenant", $this->tenant); + foreach ($additions as $type => $permissions) { foreach ($permissions as $i => $permission) { $stmtAddPermissions->bindValue(":_add_{$type}_{$i}", $permission); @@ -683,15 +732,24 @@ public function updateDocument(string $collection, Document $document): Document $sql = " UPDATE `{$this->getNamespace()}_{$name}` - SET {$columns} _uid = :_uid WHERE _uid = :_uid + SET {$columns} _uid = :_uid + WHERE _uid = :_uid "; + if ($this->shareTables) { + $sql .= " AND _tenant = :_tenant"; + } + $sql = $this->trigger(Database::EVENT_DOCUMENT_UPDATE, $sql); $stmt = $this->getPDO()->prepare($sql); $stmt->bindValue(':_uid', $document->getId()); + if ($this->shareTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $attributeIndex = 0; foreach ($attributes as $attribute => $value) { if (is_array($value)) { // arrays & objects should be saved as strings @@ -717,7 +775,8 @@ public function updateDocument(string $collection, Document $document): Document $this->getPDO()->rollBack(); throw match ($e->getCode()) { - '1062', '23000' => new Duplicate('Duplicated document: ' . $e->getMessage()), + '1062', + '23000' => new Duplicate('Duplicated document: ' . $e->getMessage()), default => $e, }; } @@ -765,6 +824,7 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($batch as $index => $document) { $attributes = $document->getAttributes(); + $attributes['_tenant'] = $this->tenant; $attributes['_uid'] = $document->getId(); $attributes['_createdAt'] = $document->getCreatedAt(); $attributes['_updatedAt'] = $document->getUpdatedAt(); @@ -790,14 +850,31 @@ public function updateDocuments(string $collection, array $documents, int $batch $batchKeys[] = '(' . implode(', ', $bindKeys) . ')'; // Permissions logic - $permissionsStmt = $this->getPDO()->prepare(" + $sql = " SELECT _type, _permission FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid - "); + "; + + if ($this->shareTables) { + $sql .= " AND _tenant = :_tenant"; + } + + $sql = $this->trigger(Database::EVENT_PERMISSIONS_READ, $sql); + + /** + * Get current permissions from the database + */ + $permissionsStmt = $this->getPDO()->prepare($sql); + $permissionsStmt->bindValue(':_uid', $document->getId()); + + if ($this->shareTables) { + $permissionsStmt->bindValue(':_tenant', $this->tenant); + } + $permissionsStmt->execute(); - $permissions = $permissionsStmt->fetchAll(PDO::FETCH_ASSOC); + $permissions = $permissionsStmt->fetchAll(); $initial = []; foreach (Database::PERMISSIONS as $type) { @@ -825,8 +902,15 @@ public function updateDocuments(string $collection, array $documents, int $batch $removeBindKeys[] = ':uid_' . $index; $removeBindValues[$bindKey] = $document->getId(); + + $tenantQuery = ''; + if ($this->shareTables) { + $tenantQuery = ' AND _tenant = :_tenant'; + } + $removeQuery .= "( _document = :uid_{$index} + {$tenantQuery} AND _type = '{$type}' AND _permission IN (" . implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; @@ -867,7 +951,7 @@ public function updateDocuments(string $collection, array $documents, int $batch $bindKey = 'add_' . $type . '_' . $index . '_' . $i; $addBindValues[$bindKey] = $permission; - $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey})"; + $addQuery .= "(:uid_{$index}, '{$type}', :{$bindKey}, :_tenant)"; if ($i !== \array_key_last($permissionsToAdd) || $type !== \array_key_last($additions)) { $addQuery .= ', '; @@ -889,11 +973,19 @@ public function updateDocuments(string $collection, array $documents, int $batch $updateClause .= "{$column} = excluded.{$column}"; } - $stmt = $this->getPDO()->prepare(" + $sql = " INSERT INTO {$this->getSQLTable($name)} (" . \implode(", ", $columns) . ") VALUES " . \implode(', ', $batchKeys) . " - ON CONFLICT(_uid) DO UPDATE SET $updateClause - "); + + "; + + if ($this->shareTables) { + $sql .= "ON CONFLICT (_tenant, _uid) DO UPDATE SET $updateClause"; + } else { + $sql .= "ON CONFLICT (_uid) DO UPDATE SET $updateClause"; + } + + $stmt = $this->getPDO()->prepare($sql); foreach ($bindValues as $key => $value) { $stmt->bindValue($key, $value, $this->getPDOType($value)); @@ -911,26 +1003,29 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($removeBindValues as $key => $value) { $stmtRemovePermissions->bindValue($key, $value, $this->getPDOType($value)); } + if ($this->shareTables) { + $stmtRemovePermissions->bindValue(':_tenant', $this->tenant); + } $stmtRemovePermissions->execute(); } if (!empty($addQuery)) { $stmtAddPermissions = $this->getPDO()->prepare(" - INSERT INTO {$this->getSQLTable($name . '_perms')} (`_document`, `_type`, `_permission`) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _tenant) VALUES {$addQuery} "); foreach ($addBindValues as $key => $value) { $stmtAddPermissions->bindValue($key, $value, $this->getPDOType($value)); } - + $stmtAddPermissions->bindValue(':_tenant', $this->tenant); $stmtAddPermissions->execute(); } } if (!$this->getPDO()->commit()) { - throw new Exception('Failed to commit transaction'); + throw new DatabaseException('Failed to commit transaction'); } return $documents; @@ -1033,7 +1128,7 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr case Database::INDEX_UNIQUE: $type = 'UNIQUE INDEX'; - $postfix = ' COLLATE NOCASE'; + $postfix = 'COLLATE NOCASE'; break; @@ -1049,13 +1144,20 @@ protected function getSQLIndex(string $collection, string $id, string $type, arr }, $attributes); foreach ($attributes as $key => $attribute) { - $order = ''; $attribute = $this->filter($attribute); - $attributes[$key] = "`$attribute`$postfix $order"; + $attributes[$key] = "`{$attribute}` {$postfix}"; } - return "CREATE {$type} `{$this->getNamespace()}_{$collection}_{$id}` ON `{$this->getNamespace()}_{$collection}` ( " . implode(', ', $attributes) . ")"; + $key = "`{$this->getNamespace()}_{$this->tenant}_{$collection}_{$id}`"; + $attributes = implode(', ', $attributes); + + if ($this->shareTables) { + $key = "`{$this->getNamespace()}_{$collection}_{$id}`"; + $attributes = "_tenant {$postfix}, {$attributes}"; + } + + return "CREATE {$type} {$key} ON `{$this->getNamespace()}_{$collection}` ({$attributes})"; } /** @@ -1077,6 +1179,17 @@ protected function getSQLPermissionsCondition(string $collection, array $roles): )"; } + /** + * Get SQL table + * + * @param string $name + * @return string + */ + protected function getSQLTable(string $name): string + { + return "{$this->getNamespace()}_{$name}"; + } + /** * Get list of keywords that cannot be used * Refference: https://www.sqlite.org/lang_keywords.html diff --git a/src/Database/Database.php b/src/Database/Database.php index d6b741d55..ca7920ac1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3,7 +3,6 @@ namespace Utopia\Database; use Exception; -use InvalidArgumentException; use Utopia\Cache\Cache; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; @@ -142,20 +141,11 @@ class Database protected array $map = []; /** - * @var array - */ - protected array $primitives = [ - self::VAR_STRING => true, - self::VAR_INTEGER => true, - self::VAR_FLOAT => true, - self::VAR_BOOLEAN => true, - ]; - - /** - * List of Internal Ids + * List of Internal attributes + * * @var array> */ - protected static array $attributes = [ + public const INTERNAL_ATTRIBUTES = [ [ '$id' => '$id', 'type' => self::VAR_STRING, @@ -165,6 +155,15 @@ class Database 'array' => false, 'filters' => [], ], + [ + '$id' => '$internalId', + 'type' => self::VAR_STRING, + 'size' => Database::LENGTH_KEY, + 'required' => true, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], [ '$id' => '$collection', 'type' => self::VAR_STRING, @@ -174,6 +173,16 @@ class Database 'array' => false, 'filters' => [], ], + [ + '$id' => '$tenant', + 'type' => self::VAR_STRING, + 'size' => 36, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], [ '$id' => '$createdAt', 'type' => Database::VAR_DATETIME, @@ -195,21 +204,26 @@ class Database 'default' => null, 'array' => false, 'filters' => ['datetime'] - ] + ], + [ + '$id' => '$permissions', + 'type' => Database::VAR_STRING, + 'size' => 1000000, + 'signed' => true, + 'required' => false, + 'default' => [], + 'array' => false, + 'filters' => ['json'] + ], ]; - /** - * List of Internal Attributes - * - * @var array - */ - public const INTERNAL_ATTRIBUTES = [ - '$id', - '$internalId', - '$createdAt', - '$updatedAt', - '$permissions', - '$collection', + public const INTERNAL_INDEXES = [ + '_id', + '_uid', + '_createdAt', + '_updatedAt', + '_permissions_id', + '_permissions', ]; /** @@ -218,7 +232,7 @@ class Database * * @var array */ - protected array $collection = [ + public const COLLECTION = [ '$id' => self::METADATA, '$collection' => self::METADATA, 'name' => 'collections', @@ -299,23 +313,23 @@ class Database protected bool $validate = true; - private int $relationshipFetchDepth = 1; + protected int $relationshipFetchDepth = 1; /** * Stack of collection IDs when creating or updating related documents * @var array */ - private array $relationshipWriteStack = []; + protected array $relationshipWriteStack = []; /** * @var array */ - private array $relationshipFetchStack = []; + protected array $relationshipFetchStack = []; /** * @var array */ - private array $relationshipDeleteStack = []; + protected array $relationshipDeleteStack = []; /** * @param Adapter $adapter @@ -536,7 +550,7 @@ public function withRequestTimestamp(?\DateTime $requestTimestamp, callable $cal * * @return $this * - * @throws Exception + * @throws DatabaseException */ public function setNamespace(string $namespace): self { @@ -551,8 +565,6 @@ public function setNamespace(string $namespace): self * Get namespace of current set scope * * @return string - * - * @throws DatabaseException */ public function getNamespace(): string { @@ -563,14 +575,15 @@ public function getNamespace(): string * Set database to use for current scope * * @param string $name - * @param bool $reset * - * @return bool - * @throws Exception + * @return self + * @throws DatabaseException */ - public function setDefaultDatabase(string $name, bool $reset = false): bool + public function setDatabase(string $name): self { - return $this->adapter->setDefaultDatabase($name, $reset); + $this->adapter->setDatabase($name); + + return $this; } /** @@ -578,13 +591,12 @@ public function setDefaultDatabase(string $name, bool $reset = false): bool * * Get Database from current scope * - * @throws Exception - * * @return string + * @throws DatabaseException */ - public function getDefaultDatabase(): string + public function getDatabase(): string { - return $this->adapter->getDefaultDatabase(); + return $this->adapter->getDatabase(); } /** @@ -671,6 +683,36 @@ public function disableValidation(): self return $this; } + /** + * Set Share Tables + * + * Set whether to share tables between tenants + * + * @param bool $share + * @return self + */ + public function setShareTables(bool $share): self + { + $this->adapter->setShareTables($share); + + return $this; + } + + /** + * Set Tenant + * + * Set tenant to use if tables are shared + * + * @param ?int $tenant + * @return self + */ + public function setTenant(?int $tenant): self + { + $this->adapter->setTenant($tenant); + + return $this; + } + /** * Ping Database * @@ -682,38 +724,32 @@ public function ping(): bool } /** - * Create the Default Database + * Create the database * - * @throws Exception + * @throws DatabaseException * * @return bool */ - public function create(): bool + public function create(?string $database = null): bool { - $name = $this->adapter->getDefaultDatabase(); - $this->adapter->create($name); + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $database = $database ?? $this->adapter->getDatabase(); + $this->adapter->create($database); /** * Create array of attribute documents * @var array $attributes */ $attributes = array_map(function ($attribute) { - return new Document([ - '$id' => ID::custom($attribute[0]), - 'type' => $attribute[1], - 'size' => $attribute[2], - 'required' => $attribute[3], - ]); - }, [ // Array of [$id, $type, $size, $required] - ['name', self::VAR_STRING, 512, true], - ['attributes', self::VAR_STRING, 1000000, false], - ['indexes', self::VAR_STRING, 1000000, false], - ['documentSecurity', self::VAR_BOOLEAN, 0, false], - ]); + return new Document($attribute); + }, self::COLLECTION['attributes']); $this->silent(fn () => $this->createCollection(self::METADATA, $attributes)); - $this->trigger(self::EVENT_DATABASE_CREATE, $name); + $this->trigger(self::EVENT_DATABASE_CREATE, $database); return true; } @@ -722,13 +758,19 @@ public function create(): bool * Check if database exists * Optionally check if collection exists in database * - * @param string $database database name + * @param string|null $database (optional) database name * @param string|null $collection (optional) collection name * * @return bool */ - public function exists(string $database, string $collection = null): bool + public function exists(?string $database = null, ?string $collection = null): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $database = $database ?? $this->adapter->getDatabase(); + return $this->adapter->exists($database, $collection); } @@ -739,6 +781,10 @@ public function exists(string $database, string $collection = null): bool */ public function list(): array { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $databases = $this->adapter->list(); $this->trigger(self::EVENT_DATABASE_LIST, $databases); @@ -749,15 +795,23 @@ public function list(): array /** * Delete Database * - * @param string $name - * + * @param string|null $database * @return bool */ - public function delete(string $name): bool + public function delete(?string $database = null): bool { - $deleted = $this->adapter->delete($name); + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $database = $database ?? $this->adapter->getDatabase(); + + $deleted = $this->adapter->delete($database); - $this->trigger(self::EVENT_DATABASE_DELETE, ['name' => $name, 'deleted' => $deleted]); + $this->trigger(self::EVENT_DATABASE_DELETE, [ + 'name' => $database, + 'deleted' => $deleted + ]); return $deleted; } @@ -773,11 +827,14 @@ public function delete(string $name): bool * @return Document * @throws DatabaseException * @throws DuplicateException - * @throws InvalidArgumentException * @throws LimitException */ public function createCollection(string $id, array $attributes = [], array $indexes = [], array $permissions = null, bool $documentSecurity = true): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $permissions ??= [ Permission::create(Role::any()), ]; @@ -785,7 +842,7 @@ public function createCollection(string $id, array $attributes = [], array $inde if ($this->validate) { $validator = new Permissions(); if (!$validator->isValid($permissions)) { - throw new InvalidArgumentException($validator->getDescription()); + throw new DatabaseException($validator->getDescription()); } } @@ -819,7 +876,7 @@ public function createCollection(string $id, array $attributes = [], array $inde $this->adapter->createCollection($id, $attributes, $indexes); if ($id === self::METADATA) { - return new Document($this->collection); + return new Document(self::COLLECTION); } // Check index limits, if given @@ -827,7 +884,7 @@ public function createCollection(string $id, array $attributes = [], array $inde throw new LimitException('Index limit of ' . $this->adapter->getLimitForIndexes() . ' exceeded. Cannot create collection.'); } - // check attribute limits, if given + // Check attribute limits, if given if ($attributes) { if ( $this->adapter->getLimitForAttributes() > 0 && @@ -859,22 +916,33 @@ public function createCollection(string $id, array $attributes = [], array $inde * @param bool $documentSecurity * * @return Document - * @throws InvalidArgumentException * @throws ConflictException * @throws DatabaseException - * @throws InvalidArgumentException */ public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if ($this->validate) { $validator = new Permissions(); if (!$validator->isValid($permissions)) { - throw new InvalidArgumentException($validator->getDescription()); + throw new DatabaseException($validator->getDescription()); } } $collection = $this->silent(fn () => $this->getCollection($id)); + if ($collection->isEmpty()) { + throw new DatabaseException('Collection not found'); + } + + if ($this->adapter->getShareTables() + && $collection->getAttribute('$tenant') != $this->adapter->getTenant()) { + throw new DatabaseException('Collection not found'); + } + $collection ->setAttribute('$permissions', $permissions) ->setAttribute('documentSecurity', $documentSecurity); @@ -896,8 +964,18 @@ public function updateCollection(string $id, array $permissions, bool $documentS */ public function getCollection(string $id): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); + if ($id !== self::METADATA + && $this->adapter->getShareTables() + && $collection->getAttribute('$tenant') != $this->adapter->getTenant()) { + return new Document(); + } + $this->trigger(self::EVENT_COLLECTION_READ, $collection); return $collection; @@ -914,11 +992,21 @@ public function getCollection(string $id): Document */ public function listCollections(int $limit = 25, int $offset = 0): array { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $result = $this->silent(fn () => $this->find(self::METADATA, [ Query::limit($limit), Query::offset($offset) ])); + if ($this->adapter->getShareTables()) { + $result = \array_filter($result, function ($collection) { + return $collection->getAttribute('$tenant') === $this->adapter->getTenant(); + }); + } + $this->trigger(self::EVENT_COLLECTION_LIST, $result); return $result; @@ -933,7 +1021,21 @@ public function listCollections(int $limit = 25, int $offset = 0): array */ public function getSizeOfCollection(string $collection): int { - return $this->adapter->getSizeOfCollection($collection); + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + throw new DatabaseException('Collection not found'); + } + + if ($this->adapter->getShareTables() && $collection->getAttribute('$tenant') != $this->adapter->getTenant()) { + throw new DatabaseException('Collection not found'); + } + + return $this->adapter->getSizeOfCollection($collection->getId()); } /** @@ -945,8 +1047,20 @@ public function getSizeOfCollection(string $collection): int */ public function deleteCollection(string $id): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getDocument(self::METADATA, $id)); + if ($collection->isEmpty()) { + throw new DatabaseException('Collection not found'); + } + + if ($this->adapter->getShareTables() && $collection->getAttribute('$tenant') != $this->adapter->getTenant()) { + throw new DatabaseException('Collection not found'); + } + $relationships = \array_filter( $collection->getAttribute('attributes'), fn ($attribute) => @@ -991,12 +1105,20 @@ public function deleteCollection(string $id): bool */ public function createAttribute(string $collection, string $id, string $type, int $size, bool $required, mixed $default = null, bool $signed = true, bool $array = false, string $format = null, array $formatOptions = [], array $filters = []): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); if ($collection->isEmpty()) { throw new DatabaseException('Collection not found'); } + if ($this->adapter->getShareTables() && $collection->getAttribute('$tenant') != $this->adapter->getTenant()) { + throw new DatabaseException('Collection not found'); + } + // attribute IDs are case insensitive $attributes = $collection->getAttribute('attributes', []); /** @var array $attributes */ @@ -1164,11 +1286,16 @@ protected function validateDefaultTypes(string $type, mixed $default): void * @throws ConflictException * @throws DatabaseException */ - private function updateIndexMeta(string $collection, string $id, callable $updateCallback): Document + protected function updateIndexMeta(string $collection, string $id, callable $updateCallback): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); + if ($collection->getId() === self::METADATA) { - throw new DatabaseException('Cannot update metadata attributes'); + throw new DatabaseException('Cannot update metadata indexes'); } $indexes = $collection->getAttribute('indexes', []); @@ -1202,9 +1329,14 @@ private function updateIndexMeta(string $collection, string $id, callable $updat * @throws ConflictException * @throws DatabaseException */ - private function updateAttributeMeta(string $collection, string $id, callable $updateCallback): Document + protected function updateAttributeMeta(string $collection, string $id, callable $updateCallback): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); + if ($collection->getId() === self::METADATA) { throw new DatabaseException('Cannot update metadata attributes'); } @@ -1439,10 +1571,10 @@ public function updateAttribute(string $collection, string $id, string $type = n throw new DatabaseException('Failed to update attribute'); } - $this->deleteCachedCollection($collection); + $this->purgeCachedCollection($collection); } - $this->deleteCachedDocument(self::METADATA, $collection); + $this->purgeCachedDocument(self::METADATA, $collection); }); } @@ -1459,6 +1591,10 @@ public function updateAttribute(string $collection, string $id, string $type = n */ public function checkAttribute(Document $collection, Document $attribute): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = clone $collection; $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); @@ -1492,6 +1628,10 @@ public function checkAttribute(Document $collection, Document $attribute): bool */ public function deleteAttribute(string $collection, string $id): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -1559,6 +1699,10 @@ public function deleteAttribute(string $collection, string $id): bool */ public function renameAttribute(string $collection, string $old, string $new): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -1633,6 +1777,10 @@ public function createRelationship( ?string $twoWayKey = null, string $onDelete = Database::RELATION_MUTATE_RESTRICT ): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); if ($collection->isEmpty()) { @@ -1820,6 +1968,10 @@ public function updateRelationship( ?bool $twoWay = null, ?string $onDelete = null ): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if ( \is_null($newKey) && \is_null($newTwoWayKey) @@ -1898,7 +2050,7 @@ public function updateRelationship( $junctionAttribute->setAttribute('key', $newTwoWayKey); }); - $this->deleteCachedCollection($junction); + $this->purgeCachedCollection($junction); } if ($altering) { @@ -1918,8 +2070,8 @@ public function updateRelationship( } } - $this->deleteCachedCollection($collection->getId()); - $this->deleteCachedCollection($relatedCollection->getId()); + $this->purgeCachedCollection($collection->getId()); + $this->purgeCachedCollection($relatedCollection->getId()); $renameIndex = function (string $collection, string $key, string $newKey) { $this->updateIndexMeta( @@ -1985,6 +2137,10 @@ public function updateRelationship( */ public function deleteRelationship(string $collection, string $id): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); $attributes = $collection->getAttribute('attributes', []); $relationship = null; @@ -2077,8 +2233,8 @@ public function deleteRelationship(string $collection, string $id): bool throw new DatabaseException('Failed to delete relationship'); } - $this->deleteCachedCollection($collection->getId()); - $this->deleteCachedCollection($relatedCollection->getId()); + $this->purgeCachedCollection($collection->getId()); + $this->purgeCachedCollection($relatedCollection->getId()); $this->trigger(self::EVENT_ATTRIBUTE_DELETE, $relationship); @@ -2101,6 +2257,10 @@ public function deleteRelationship(string $collection, string $id): bool */ public function renameIndex(string $collection, string $old, string $new): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); $indexes = $collection->getAttribute('indexes', []); @@ -2160,6 +2320,10 @@ public function renameIndex(string $collection, string $old, string $new): bool */ public function createIndex(string $collection, string $id, string $type, array $attributes, array $lengths = [], array $orders = []): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if (empty($attributes)) { throw new DatabaseException('Missing attributes'); } @@ -2249,6 +2413,10 @@ public function createIndex(string $collection, string $id, string $type, array */ public function deleteIndex(string $collection, string $id): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); $indexes = $collection->getAttribute('indexes', []); @@ -2283,11 +2451,16 @@ public function deleteIndex(string $collection, string $id): bool * * @return Document * @throws DatabaseException + * @throws Exception */ public function getDocument(string $collection, string $id, array $queries = []): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if ($collection === self::METADATA && $id === self::METADATA) { - return new Document($this->collection); + return new Document(self::COLLECTION); } if (empty($collection)) { @@ -2360,7 +2533,7 @@ public function getDocument(string $collection, string $id, array $queries = []) $validator = new Authorization(self::PERMISSION_READ); $documentSecurity = $collection->getAttribute('documentSecurity', false); - $cacheKey = 'cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $id; + $cacheKey = 'cache-' . $this->getNamespace() . ':' . $this->adapter->getTenant() . ':' . $collection->getId() . ':' . $id; if (!empty($selections)) { $cacheKey .= ':' . \md5(\implode($selections)); @@ -2430,13 +2603,13 @@ public function getDocument(string $collection, string $id, array $queries = []) * @phpstan-ignore-next-line */ foreach ($this->map as $key => $value) { - list($k, $v) = explode('=>', $key); + [$k, $v] = \explode('=>', $key); $ck = 'cache-' . $this->getNamespace() . ':map:' . $k; $cache = $this->cache->load($ck, self::TTL); if (empty($cache)) { $cache = []; } - if (!in_array($v, $cache)) { + if (!\in_array($v, $cache)) { $cache[] = $v; $this->cache->save($ck, $cache); } @@ -2454,8 +2627,8 @@ public function getDocument(string $collection, string $id, array $queries = []) if ($query->getMethod() === Query::TYPE_SELECT) { $values = $query->getValues(); foreach (Database::INTERNAL_ATTRIBUTES as $internalAttribute) { - if (!in_array($internalAttribute, $values)) { - $document->removeAttribute($internalAttribute); + if (!in_array($internalAttribute['$id'], $values)) { + $document->removeAttribute($internalAttribute['$id']); } } } @@ -2713,6 +2886,10 @@ private function populateDocumentRelationships(Document $collection, Document $d */ public function createDocument(string $collection, Document $document): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); if ($collection->getId() !== self::METADATA) { @@ -2735,7 +2912,7 @@ public function createDocument(string $collection, Document $document): Document if ($this->validate) { $validator = new Permissions(); if (!$validator->isValid($document->getPermissions())) { - throw new InvalidArgumentException($validator->getDescription()); + throw new DatabaseException($validator->getDescription()); } } @@ -2776,6 +2953,10 @@ public function createDocument(string $collection, Document $document): Document */ public function createDocuments(string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE): array { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if (empty($documents)) { return []; } @@ -2785,6 +2966,12 @@ public function createDocuments(string $collection, array $documents, int $batch $time = DateTime::now(); foreach ($documents as $key => $document) { + if ($this->adapter->getShareTables()) { + if (empty($document->getAttribute('$tenant'))) { + throw new DatabaseException('Missing tenant. Tenant must be included when isolation mode is set to "table".'); + } + } + $document ->setAttribute('$id', empty($document->getId()) ? ID::unique() : $document->getId()) ->setAttribute('$collection', $collection->getId()) @@ -3071,7 +3258,7 @@ private function relateDocumentsById( } break; case Database::RELATION_MANY_TO_MANY: - $this->deleteCachedDocument($relatedCollection->getId(), $relationId); + $this->purgeCachedDocument($relatedCollection->getId(), $relationId); $junction = $this->getJunctionCollection($collection, $relatedCollection, $side); @@ -3103,6 +3290,10 @@ private function relateDocumentsById( */ public function updateDocument(string $collection, string $id, Document $document): Document { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if (!$document->getId() || !$id) { throw new DatabaseException('Must define $id attribute'); } @@ -3110,11 +3301,13 @@ public function updateDocument(string $collection, string $id, Document $documen $time = DateTime::now(); $old = Authorization::skip(fn () => $this->silent(fn () => $this->getDocument($collection, $id))); // Skip ensures user does not need read permission for this $document = \array_merge($old->getArrayCopy(), $document->getArrayCopy()); - $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collectionID + $document['$collection'] = $old->getAttribute('$collection'); // Make sure user doesn't switch collection ID + $document['$tenant'] = $old->getAttribute('$tenant'); // Make sure user doesn't switch tenant $document['$createdAt'] = $old->getCreatedAt(); // Make sure user doesn't switch createdAt $document = new Document($document); $collection = $this->silent(fn () => $this->getCollection($collection)); + $relationships = \array_filter($collection->getAttribute('attributes', []), function ($attribute) { return $attribute['type'] === Database::VAR_RELATIONSHIP; }); @@ -3246,7 +3439,7 @@ public function updateDocument(string $collection, string $id, Document $documen $this->purgeRelatedDocuments($collection, $id); - $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $id . ':*'); + $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); @@ -3268,6 +3461,10 @@ public function updateDocument(string $collection, string $id, Document $documen */ public function updateDocuments(string $collection, array $documents, int $batchSize = self::INSERT_BATCH_SIZE): array { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if (empty($documents)) { return []; } @@ -3276,12 +3473,13 @@ public function updateDocuments(string $collection, array $documents, int $batch $collection = $this->silent(fn () => $this->getCollection($collection)); foreach ($documents as $document) { - if (!$document->getId()) { - throw new Exception('Must define $id attribute for each document'); + if ($this->adapter->getShareTables() && empty($document->getAttribute('$tenant'))) { + throw new DatabaseException('Missing tenant. Tenant must be included when isolation mode is set to "table".'); } - $document->setAttribute('$updatedAt', $time); - $document = $this->encode($collection, $document); + if (!$document->getId()) { + throw new DatabaseException('Must define $id attribute for each document'); + } $old = Authorization::skip(fn () => $this->silent( fn () => $this->getDocument( @@ -3290,6 +3488,9 @@ public function updateDocuments(string $collection, array $documents, int $batch ) )); + $document->setAttribute('$updatedAt', $time); + $document = $this->encode($collection, $document); + $validator = new Authorization(self::PERMISSION_UPDATE); if ($collection->getId() !== self::METADATA && !$validator->isValid($old->getUpdate())) { @@ -3307,7 +3508,7 @@ public function updateDocuments(string $collection, array $documents, int $batch foreach ($documents as $key => $document) { $documents[$key] = $this->decode($collection, $document); - $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $document->getId() . ':*'); + $this->purgeCachedDocument($collection->getId(), $document->getId()); } $this->trigger(self::EVENT_DOCUMENTS_UPDATE, $documents); @@ -3565,7 +3766,7 @@ private function updateDocumentRelationships(Document $collection, Document $old // For many-one we need to update the related key to null if no relation exists $document->setAttribute($key, null); } - $this->deleteCachedDocument($relatedCollection->getId(), $value); + $this->purgeCachedDocument($relatedCollection->getId(), $value); } elseif ($value instanceof Document) { $related = $this->getDocument($relatedCollection->getId(), $value->getId()); @@ -3583,12 +3784,15 @@ private function updateDocumentRelationships(Document $collection, Document $old $related->getId(), $value ); - $this->deleteCachedDocument($relatedCollection->getId(), $related->getId()); + $this->purgeCachedDocument($relatedCollection->getId(), $related->getId()); } $document->setAttribute($key, $value->getId()); } elseif (\is_null($value)) { break; + } elseif (empty($value)) { + throw new DatabaseException('Invalid value for relationship'); + } else { throw new DatabaseException('Invalid value for relationship'); } @@ -3710,6 +3914,10 @@ private function getJunctionCollection(Document $collection, Document $relatedCo */ public function increaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value = 1, int|float|null $max = null): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if ($value <= 0) { // Can be a float throw new DatabaseException('Value must be numeric and greater than 0'); } @@ -3754,7 +3962,8 @@ public function increaseDocumentAttribute(string $collection, string $id, string $max = $max ? $max - $value : null; $result = $this->adapter->increaseDocumentAttribute($collection->getId(), $id, $attribute, $value, null, $max); - $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $id . ':*'); + + $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_INCREASE, $document); @@ -3777,6 +3986,10 @@ public function increaseDocumentAttribute(string $collection, string $id, string */ public function decreaseDocumentAttribute(string $collection, string $id, string $attribute, int|float $value = 1, int|float|null $min = null): bool { + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + if ($value <= 0) { // Can be a float throw new DatabaseException('Value must be numeric and greater than 0'); } @@ -3821,7 +4034,7 @@ public function decreaseDocumentAttribute(string $collection, string $id, string $min = $min ? $min + $value : null; $result = $this->adapter->increaseDocumentAttribute($collection->getId(), $id, $attribute, $value * -1, $min); - $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $id . ':*'); + $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DECREASE, $document); return $result; @@ -3843,7 +4056,12 @@ public function decreaseDocumentAttribute(string $collection, string $id, string */ public function deleteDocument(string $collection, string $id): bool { - $document = Authorization::skip(fn () => $this->silent(fn () => $this->getDocument($collection, $id))); // Skip ensures user does not need read permission for this + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + + $document = Authorization::skip(fn () => $this->silent(fn () => $this->getDocument($collection, $id))); + $collection = $this->silent(fn () => $this->getCollection($collection)); $validator = new Authorization(self::PERMISSION_DELETE); @@ -3876,7 +4094,7 @@ public function deleteDocument(string $collection, string $id): bool $deleted = $this->adapter->deleteDocument($collection->getId(), $id); $this->purgeRelatedDocuments($collection, $id); - $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection->getId() . ':' . $id . ':*'); + $this->purgeCachedDocument($collection->getId(), $id); $this->trigger(self::EVENT_DOCUMENT_DELETE, $document); @@ -4260,9 +4478,9 @@ private function deleteCascade(Document $collection, Document $relatedCollection * @return bool * @throws DatabaseException */ - public function deleteCachedCollection(string $collection): bool + public function purgeCachedCollection(string $collection): bool { - return $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection . ':*'); + return $this->cache->purge('cache-' . $this->getNamespace() . ':' . $this->adapter->getTenant() . ':' . $collection . ':*'); } /** @@ -4274,9 +4492,9 @@ public function deleteCachedCollection(string $collection): bool * @return bool * @throws DatabaseException */ - public function deleteCachedDocument(string $collection, string $id): bool + public function purgeCachedDocument(string $collection, string $id): bool { - return $this->cache->purge('cache-' . $this->getNamespace() . ':' . $collection . ':' . $id . ':*'); + return $this->cache->purge('cache-' . $this->getNamespace() . ':' . $this->adapter->getTenant() . ':' . $collection . ':' . $id . ':*'); } /** @@ -4292,11 +4510,14 @@ public function deleteCachedDocument(string $collection, string $id): bool */ public function find(string $collection, array $queries = []): array { - $originalName = $collection; + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); + } + $collection = $this->silent(fn () => $this->getCollection($collection)); if ($collection->isEmpty()) { - throw new DatabaseException('Collection "'. $originalName .'" not found'); + throw new DatabaseException('Collection not found'); } $attributes = $collection->getAttribute('attributes', []); @@ -4414,14 +4635,16 @@ public function find(string $collection, array $queries = []): array } } + unset($query); + // Remove internal attributes which are not queried foreach ($queries as $query) { if ($query->getMethod() === Query::TYPE_SELECT) { $values = $query->getValues(); foreach ($results as $result) { foreach (Database::INTERNAL_ATTRIBUTES as $internalAttribute) { - if (!\in_array($internalAttribute, $values)) { - $result->removeAttribute($internalAttribute); + if (!\in_array($internalAttribute['$id'], $values)) { + $result->removeAttribute($internalAttribute['$id']); } } } @@ -4466,12 +4689,11 @@ public function findOne(string $collection, array $queries = []): false|Document */ public function count(string $collection, array $queries = [], ?int $max = null): int { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new DatabaseException("Collection not found"); + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); } + $collection = $this->silent(fn () => $this->getCollection($collection)); $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -4513,12 +4735,11 @@ public function count(string $collection, array $queries = [], ?int $max = null) */ public function sum(string $collection, string $attribute, array $queries = [], ?int $max = null): float|int { - $collection = $this->silent(fn () => $this->getCollection($collection)); - - if ($collection->isEmpty()) { - throw new DatabaseException("Collection not found"); + if ($this->adapter->getShareTables() && empty($this->adapter->getTenant())) { + throw new DatabaseException('Missing tenant. Tenant must be set when table sharing is enabled.'); } + $collection = $this->silent(fn () => $this->getCollection($collection)); $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -4555,19 +4776,6 @@ public static function addFilter(string $name, callable $encode, callable $decod ]; } - /** - * @return array - * @throws DatabaseException - */ - public static function getInternalAttributes(): array - { - $attributes = []; - foreach (self::$attributes as $internal) { - $attributes[] = new Document($internal); - } - return $attributes; - } - /** * Encode Document * @@ -4580,7 +4788,14 @@ public static function getInternalAttributes(): array public function encode(Document $collection, Document $document): Document { $attributes = $collection->getAttribute('attributes', []); - $attributes = array_merge($attributes, $this->getInternalAttributes()); + + $internalAttributes = \array_filter(Database::INTERNAL_ATTRIBUTES, function ($attribute) { + // We don't want to encode permissions into a JSON string + return $attribute['$id'] !== '$permissions'; + }); + + $attributes = \array_merge($attributes, $internalAttributes); + foreach ($attributes as $attribute) { $key = $attribute['$id'] ?? ''; $array = $attribute['array'] ?? false; @@ -4657,7 +4872,7 @@ public function decode(Document $collection, Document $document, array $selectio } } - $attributes = array_merge($attributes, $this->getInternalAttributes()); + $attributes = array_merge($attributes, Database::INTERNAL_ATTRIBUTES); foreach ($attributes as $attribute) { $key = $attribute['$id'] ?? ''; @@ -4846,10 +5061,11 @@ private function validateSelections(Document $collection, array $queries): array } } - $keys = []; - // Allow querying internal attributes - $keys = array_merge($keys, self::INTERNAL_ATTRIBUTES); + $keys = \array_map( + fn ($attribute) => $attribute['$id'], + self::INTERNAL_ATTRIBUTES + ); foreach ($collection->getAttribute('attributes', []) as $attribute) { if ($attribute['type'] !== self::VAR_RELATIONSHIP) { @@ -4992,7 +5208,7 @@ private function purgeRelatedDocuments(Document $collection, string $id): void if (!empty($cache)) { foreach ($cache as $v) { list($collectionId, $documentId) = explode(':', $v); - $this->deleteCachedDocument($collectionId, $documentId); + $this->purgeCachedDocument($collectionId, $documentId); } $this->cache->purge($key); } diff --git a/src/Database/Document.php b/src/Database/Document.php index 6311841b4..7afddc25f 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -4,8 +4,6 @@ use ArrayObject; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Helpers\Permission; -use Utopia\Database\Helpers\Role; /** * @extends ArrayObject @@ -168,15 +166,13 @@ public function getAttributes(): array { $attributes = []; + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + Database::INTERNAL_ATTRIBUTES + ); + foreach ($this as $attribute => $value) { - if (\array_key_exists($attribute, [ - '$id' => true, - '$internalId' => true, - '$collection' => true, - '$permissions' => [Permission::read(Role::any())], - '$createdAt' => true, - '$updatedAt' => true, - ])) { + if (\in_array($attribute, $internalKeys)) { continue; } diff --git a/src/Database/Validator/Index.php b/src/Database/Validator/Index.php index a0b6b80f5..6cdf9d89c 100644 --- a/src/Database/Validator/Index.php +++ b/src/Database/Validator/Index.php @@ -20,6 +20,7 @@ class Index extends Validator /** * @param array $attributes * @param int $maxLength + * @throws DatabaseException */ public function __construct(array $attributes, int $maxLength) { @@ -29,9 +30,9 @@ public function __construct(array $attributes, int $maxLength) $key = \strtolower($attribute->getAttribute('key', $attribute->getAttribute('$id'))); $this->attributes[$key] = $attribute; } - foreach (Database::getInternalAttributes() as $attribute) { - $key = \strtolower($attribute->getAttribute('$id')); - $this->attributes[$key] = $attribute; + foreach (Database::INTERNAL_ATTRIBUTES as $attribute) { + $key = \strtolower($attribute['$id']); + $this->attributes[$key] = new Document($attribute); } } diff --git a/src/Database/Validator/Queries/Document.php b/src/Database/Validator/Queries/Document.php index fbb164d9d..41c9f3f9b 100644 --- a/src/Database/Validator/Queries/Document.php +++ b/src/Database/Validator/Queries/Document.php @@ -21,14 +21,12 @@ public function __construct(array $attributes) 'type' => Database::VAR_STRING, 'array' => false, ]); - $attributes[] = new \Utopia\Database\Document([ '$id' => '$createdAt', 'key' => '$createdAt', 'type' => Database::VAR_DATETIME, 'array' => false, ]); - $attributes[] = new \Utopia\Database\Document([ '$id' => '$updatedAt', 'key' => '$updatedAt', diff --git a/src/Database/Validator/Query/Select.php b/src/Database/Validator/Query/Select.php index 833284c5e..e00c3916e 100644 --- a/src/Database/Validator/Query/Select.php +++ b/src/Database/Validator/Query/Select.php @@ -2,6 +2,7 @@ namespace Utopia\Database\Validator\Query; +use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Query; @@ -56,6 +57,11 @@ public function isValid($value): bool return false; } + $internalKeys = \array_map( + fn ($attr) => $attr['$id'], + Database::INTERNAL_ATTRIBUTES + ); + foreach ($value->getValues() as $attribute) { if (\str_contains($attribute, '.')) { //special symbols with `dots` @@ -69,7 +75,7 @@ public function isValid($value): bool } // Skip internal attributes - if (\in_array($attribute, self::INTERNAL_ATTRIBUTES)) { + if (\in_array($attribute, $internalKeys)) { continue; } diff --git a/src/Database/Validator/Structure.php b/src/Database/Validator/Structure.php index 14223b1a5..3d7d2ade7 100644 --- a/src/Database/Validator/Structure.php +++ b/src/Database/Validator/Structure.php @@ -52,6 +52,16 @@ class Structure extends Validator 'array' => false, 'filters' => [], ], + [ + '$id' => '$tenant', + 'type' => Database::VAR_STRING, + 'size' => 36, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ], [ '$id' => '$permissions', 'type' => Database::VAR_STRING, @@ -208,7 +218,7 @@ public function isValid($document): bool } if (empty($this->collection->getId()) || Database::METADATA !== $this->collection->getCollection()) { - $this->message = 'Collection "'.$this->collection->getCollection().'" not found'; + $this->message = 'Collection not found'; return false; } diff --git a/tests/Database/Base.php b/tests/e2e/Adapter/Base.php similarity index 98% rename from tests/Database/Base.php rename to tests/e2e/Adapter/Base.php index f38f70b25..95cdd9cae 100644 --- a/tests/Database/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -1,34 +1,37 @@ getDatabase()->getAdapter()->getSupportForSchemas(); if (!$schemaSupport) { - $this->assertEquals(true, static::getDatabase()->setDefaultDatabase($this->testDatabase)); + $this->assertEquals(static::getDatabase(), static::getDatabase()->setDatabase($this->testDatabase)); $this->assertEquals(true, static::getDatabase()->create()); return; } @@ -71,7 +74,7 @@ public function testCreateExistsDelete(): void $this->assertEquals(true, static::getDatabase()->exists($this->testDatabase)); $this->assertEquals(true, static::getDatabase()->delete($this->testDatabase)); $this->assertEquals(false, static::getDatabase()->exists($this->testDatabase)); - $this->assertEquals(true, static::getDatabase()->setDefaultDatabase($this->testDatabase)); + $this->assertEquals(static::getDatabase(), static::getDatabase()->setDatabase($this->testDatabase)); $this->assertEquals(true, static::getDatabase()->create()); } @@ -233,7 +236,7 @@ public function testQueryTimeout(): void for ($i = 0 ; $i <= 20 ; $i++) { static::getDatabase()->createDocument('global-timeouts', new Document([ - 'longtext' => file_get_contents(__DIR__ . '/../resources/longtext.txt'), + 'longtext' => file_get_contents(__DIR__ . '/../../resources/longtext.txt'), '$permissions' => [ Permission::read(Role::any()), Permission::update(Role::any()), @@ -242,7 +245,7 @@ public function testQueryTimeout(): void ])); } - $this->expectException(Timeout::class); + $this->expectException(TimeoutException::class); static::getDatabase()->setTimeout(1); @@ -250,7 +253,7 @@ public function testQueryTimeout(): void static::getDatabase()->find('global-timeouts', [ Query::notEqual('longtext', 'appwrite'), ]); - } catch(Timeout $ex) { + } catch(TimeoutException $ex) { static::getDatabase()->clearTimeout(); static::getDatabase()->deleteCollection('global-timeouts'); throw $ex; @@ -523,7 +526,7 @@ public function testCollectionNotFound(): void static::getDatabase()->find('not_exist', []); $this->fail('Failed to throw Exception'); } catch (Exception $e) { - $this->assertEquals('Collection "not_exist" not found', $e->getMessage()); + $this->assertEquals('Collection not found', $e->getMessage()); } } @@ -3943,7 +3946,7 @@ public function testGetAttributeLimit(): void public function testGetIndexLimit(): void { - $this->assertEquals(59, $this->getDatabase()->getLimitForIndexes()); + $this->assertEquals(58, $this->getDatabase()->getLimitForIndexes()); } public function testGetId(): void @@ -11411,7 +11414,7 @@ public function testCollectionUpdate(): Document */ public function testCollectionUpdatePermissionsThrowException(Document $collection): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(DatabaseException::class); static::getDatabase()->updateCollection($collection->getId(), permissions: [ 'i dont work' ], documentSecurity: false); @@ -11441,7 +11444,7 @@ public function testCollectionPermissions(): Document public function testCollectionPermissionsExceptions(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(DatabaseException::class); static::getDatabase()->createCollection('collectionSecurity', permissions: [ 'i dont work' ]); @@ -12596,6 +12599,160 @@ public function testEmptyOperatorValues(): void } } + /** + * @throws AuthorizationException + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws QueryException + * @throws StructureException + * @throws TimeoutException + */ + public function testIsolationModes(): void + { + /** + * Default mode already tested, we'll test 'schema' and 'table' isolation here + */ + $database = static::getDatabase(); + + if ($database->exists('schema1')) { + $database->setDatabase('schema1')->delete(); + } + if ($database->exists('schema2')) { + $database->setDatabase('schema2')->delete(); + } + if ($database->exists('sharedTables')) { + $database->setDatabase('sharedTables')->delete(); + } + + /** + * Schema + */ + $database + ->setDatabase('schema1') + ->setNamespace('') + ->create(); + + if ($database->getAdapter()->getSupportForSchemas()) { + $this->assertEquals(true, $database->exists('schema1')); + } + + $database + ->setDatabase('schema2') + ->setNamespace('') + ->create(); + + if ($database->getAdapter()->getSupportForSchemas()) { + $this->assertEquals(true, $database->exists('schema2')); + } + + /** + * Table + */ + + $tenant1 = 1; + $tenant2 = 2; + + $database + ->setDatabase('sharedTables') + ->setNamespace('') + ->setShareTables(true) + ->setTenant($tenant1) + ->create(); + + if ($database->getAdapter()->getSupportForSchemas()) { + $this->assertEquals(true, $database->exists('sharedTables')); + } + + $database->createCollection('people', [ + new Document([ + '$id' => 'name', + 'type' => Database::VAR_STRING, + 'size' => 128, + 'required' => true, + ]), + new Document([ + '$id' => 'lifeStory', + 'type' => Database::VAR_STRING, + 'size' => 65536, + 'required' => true, + ]) + ], [ + new Document([ + '$id' => 'idx_name', + 'type' => Database::INDEX_KEY, + 'attributes' => ['name'] + ]) + ]); + + if ($database->getAdapter()->getSupportForFulltextIndex()) { + $database->createIndex( + collection: 'people', + id: 'idx_lifeStory', + type: Database::INDEX_FULLTEXT, + attributes: ['lifeStory'] + ); + } + + $docId = ID::unique(); + + $database->createDocument('people', new Document([ + '$id' => $docId, + '$permissions' => [ + Permission::read(Role::any()), + ], + 'name' => 'Spiderman', + 'lifeStory' => 'Spider-Man is a superhero appearing in American comic books published by Marvel Comics.' + ])); + + $doc = $database->getDocument('people', $docId); + $this->assertEquals('Spiderman', $doc['name']); + $this->assertEquals($tenant1, $doc->getAttribute('$tenant')); + + $docs = $database->find('people'); + $this->assertEquals(1, \count($docs)); + + // Swap to tenant 2, no access + $database->setTenant($tenant2); + + try { + $database->getDocument('people', $docId); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Collection not found', $e->getMessage()); + } + + try { + $database->find('people'); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Collection not found', $e->getMessage()); + } + + // Swap back to tenant 1, allowed + $database->setTenant($tenant1); + + $doc = $database->getDocument('people', $docId); + $this->assertEquals('Spiderman', $doc['name']); + $docs = $database->find('people'); + $this->assertEquals(1, \count($docs)); + + // Remove tenant but leave shared tables enabled + $database->setTenant(null); + + try { + $database->getDocument('people', $docId); + $this->fail('Failed to throw exception'); + } catch (Exception $e) { + $this->assertEquals('Missing tenant. Tenant must be set when table sharing is enabled.', $e->getMessage()); + } + + // Reset state + $database->setShareTables(false); + $database->setNamespace(static::$namespace); + $database->setDatabase($this->testDatabase); + } + public function testTransformations(): void { static::getDatabase()->createCollection('docs', attributes: [ @@ -12658,7 +12815,7 @@ public function testEvents(): void }); if ($this->getDatabase()->getAdapter()->getSupportForSchemas()) { - $database->setDefaultDatabase('hellodb'); + $database->setDatabase('hellodb'); $database->create(); } else { array_shift($events); @@ -12666,7 +12823,7 @@ public function testEvents(): void $database->list(); - $database->setDefaultDatabase($this->testDatabase); + $database->setDatabase($this->testDatabase); $collectionId = ID::unique(); $database->createCollection($collectionId); diff --git a/tests/Database/Adapter/MariaDBTest.php b/tests/e2e/Adapter/MariaDBTest.php similarity index 75% rename from tests/Database/Adapter/MariaDBTest.php rename to tests/e2e/Adapter/MariaDBTest.php index d5014432e..e26ca69c2 100644 --- a/tests/Database/Adapter/MariaDBTest.php +++ b/tests/e2e/Adapter/MariaDBTest.php @@ -1,20 +1,19 @@ setDefaultDatabase('utopiaTests'); - $database->setNamespace('myapp_' . uniqid()); + $database->setDatabase('utopiaTests'); + $database->setNamespace(static::$namespace = 'myapp_' . uniqid()); if ($database->exists('utopiaTests')) { $database->delete('utopiaTests'); diff --git a/tests/Database/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php similarity index 87% rename from tests/Database/Adapter/MongoDBTest.php rename to tests/e2e/Adapter/MongoDBTest.php index 62c48ae37..887c26ec2 100644 --- a/tests/Database/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -1,20 +1,19 @@ setDefaultDatabase($schema); - $database->setNamespace('myapp_' . uniqid()); + $database->setDatabase($schema); + $database->setNamespace(static::$namespace = 'myapp_' . uniqid()); if ($database->exists('utopiaTests')) { $database->delete('utopiaTests'); @@ -73,7 +72,7 @@ public function testCreateExistsDelete(): void $this->assertNotNull(static::getDatabase()->create()); $this->assertEquals(true, static::getDatabase()->delete($this->testDatabase)); $this->assertEquals(true, static::getDatabase()->create()); - $this->assertEquals(true, static::getDatabase()->setDefaultDatabase($this->testDatabase)); + $this->assertEquals(static::getDatabase(), static::getDatabase()->setDatabase($this->testDatabase)); } public function testRenameAttribute(): void diff --git a/tests/Database/Adapter/MySQLTest.php b/tests/e2e/Adapter/MySQLTest.php similarity index 77% rename from tests/Database/Adapter/MySQLTest.php rename to tests/e2e/Adapter/MySQLTest.php index 0faefca61..d204e8a40 100644 --- a/tests/Database/Adapter/MySQLTest.php +++ b/tests/e2e/Adapter/MySQLTest.php @@ -1,20 +1,19 @@ setDefaultDatabase('utopiaTests'); - $database->setNamespace('myapp_'.uniqid()); + $database->setDatabase('utopiaTests'); + $database->setNamespace(static::$namespace = 'myapp_' . uniqid()); if ($database->exists('utopiaTests')) { $database->delete('utopiaTests'); diff --git a/tests/Database/Adapter/PostgresTest.php b/tests/e2e/Adapter/PostgresTest.php similarity index 86% rename from tests/Database/Adapter/PostgresTest.php rename to tests/e2e/Adapter/PostgresTest.php index 9965a8684..6a12ecd8c 100644 --- a/tests/Database/Adapter/PostgresTest.php +++ b/tests/e2e/Adapter/PostgresTest.php @@ -1,18 +1,18 @@ setDefaultDatabase('utopiaTests'); - $database->setNamespace('myapp_'.uniqid()); + $database->setDatabase('utopiaTests'); + $database->setNamespace(static::$namespace = 'myapp_' . uniqid()); if ($database->exists('utopiaTests')) { $database->delete('utopiaTests'); diff --git a/tests/Database/Adapter/SQLiteTest.php b/tests/e2e/Adapter/SQLiteTest.php similarity index 61% rename from tests/Database/Adapter/SQLiteTest.php rename to tests/e2e/Adapter/SQLiteTest.php index 8a8d24199..16c36f6fd 100644 --- a/tests/Database/Adapter/SQLiteTest.php +++ b/tests/e2e/Adapter/SQLiteTest.php @@ -1,20 +1,19 @@ connect('redis', 6379); + $redis->connect('redis'); $redis->flushAll(); $cache = new Cache(new RedisAdapter($redis)); $database = new Database(new SQLite($pdo), $cache); - $database->setDefaultDatabase('utopiaTests'); - $database->setNamespace('myapp_'.uniqid()); + $database->setDatabase('utopiaTests'); + $database->setNamespace(static::$namespace = 'myapp_' . uniqid()); + + if ($database->exists()) { + $database->delete(); + } + + $database->create(); return self::$database = $database; } diff --git a/tests/Database/DocumentTest.php b/tests/unit/DocumentTest.php similarity index 99% rename from tests/Database/DocumentTest.php rename to tests/unit/DocumentTest.php index acb9e7be0..4d2e517fc 100644 --- a/tests/Database/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -1,10 +1,10 @@ 'team@appwrite.io', ]))); - $this->assertEquals('Invalid document structure: Collection "" not found', $validator->getDescription()); + $this->assertEquals('Invalid document structure: Collection not found', $validator->getDescription()); } public function testRequiredKeys(): void diff --git a/tests/Database/Validator/UIDTest.php b/tests/unit/Validator/UIDTest.php similarity index 55% rename from tests/Database/Validator/UIDTest.php rename to tests/unit/Validator/UIDTest.php index e7c357bb3..c88fd9563 100644 --- a/tests/Database/Validator/UIDTest.php +++ b/tests/unit/Validator/UIDTest.php @@ -1,6 +1,6 @@