diff --git a/composer.json b/composer.json index cbf90ee53..4a0fecbd2 100755 --- a/composer.json +++ b/composer.json @@ -56,8 +56,8 @@ }, "config": { "allow-plugins": { - "php-http/discovery": true, - "tbachert/spi": true + "php-http/discovery": false, + "tbachert/spi": false } } } diff --git a/composer.lock b/composer.lock index 3abe843d8..774cd790d 100644 --- a/composer.lock +++ b/composer.lock @@ -337,16 +337,16 @@ }, { "name": "open-telemetry/api", - "version": "1.2.3", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "199d7ddda88f5f5619fa73463f1a5a7149ccd1f1" + "reference": "4e3bb38e069876fb73c2ce85c89583bf2b28cd86" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/199d7ddda88f5f5619fa73463f1a5a7149ccd1f1", - "reference": "199d7ddda88f5f5619fa73463f1a5a7149ccd1f1", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/4e3bb38e069876fb73c2ce85c89583bf2b28cd86", + "reference": "4e3bb38e069876fb73c2ce85c89583bf2b28cd86", "shasum": "" }, "require": { @@ -403,20 +403,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-03-05T21:42:54+00:00" + "time": "2025-05-07T12:32:21+00:00" }, { "name": "open-telemetry/context", - "version": "1.2.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/context.git", - "reference": "5f553042b951d3fedf47925852c380159dfca801" + "reference": "1eb2b837ee9362db064a6b65d5ecce15a9f9f020" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/5f553042b951d3fedf47925852c380159dfca801", - "reference": "5f553042b951d3fedf47925852c380159dfca801", + "url": "https://api.github.com/repos/opentelemetry-php/context/zipball/1eb2b837ee9362db064a6b65d5ecce15a9f9f020", + "reference": "1eb2b837ee9362db064a6b65d5ecce15a9f9f020", "shasum": "" }, "require": { @@ -462,20 +462,20 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-02T01:57:57+00:00" + "time": "2025-05-07T23:36:50+00:00" }, { "name": "open-telemetry/exporter-otlp", - "version": "1.2.1", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/exporter-otlp.git", - "reference": "b7580440b7481a98da97aceabeb46e1b276c8747" + "reference": "19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/b7580440b7481a98da97aceabeb46e1b276c8747", - "reference": "b7580440b7481a98da97aceabeb46e1b276c8747", + "url": "https://api.github.com/repos/opentelemetry-php/exporter-otlp/zipball/19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c", + "reference": "19adf03d2b0f91f9e9b1c7f93db6c755c737cf6c", "shasum": "" }, "require": { @@ -526,7 +526,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-03-06T23:21:56+00:00" + "time": "2025-05-12T00:36:35+00:00" }, { "name": "open-telemetry/gen-otlp-protobuf", @@ -593,16 +593,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.3.0", + "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "05d9ceb6773b5bddcf485af6d4a6f543bbeb980b" + "reference": "939d3a28395c249a763676458140dad44b3a8011" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/05d9ceb6773b5bddcf485af6d4a6f543bbeb980b", - "reference": "05d9ceb6773b5bddcf485af6d4a6f543bbeb980b", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/939d3a28395c249a763676458140dad44b3a8011", + "reference": "939d3a28395c249a763676458140dad44b3a8011", "shasum": "" }, "require": { @@ -679,7 +679,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-05-01T23:20:43+00:00" + "time": "2025-05-07T12:32:21+00:00" }, { "name": "open-telemetry/sem-conv", @@ -1782,16 +1782,16 @@ }, { "name": "utopia-php/cache", - "version": "0.13.0", + "version": "0.13.1", "source": { "type": "git", "url": "https://github.com/utopia-php/cache.git", - "reference": "dee01dec33a211644d60f6cfa56b1b8176d3fae3" + "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cache/zipball/dee01dec33a211644d60f6cfa56b1b8176d3fae3", - "reference": "dee01dec33a211644d60f6cfa56b1b8176d3fae3", + "url": "https://api.github.com/repos/utopia-php/cache/zipball/97220cb3b3822b166ee016d1646e2ae2815dc540", + "reference": "97220cb3b3822b166ee016d1646e2ae2815dc540", "shasum": "" }, "require": { @@ -1828,9 +1828,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cache/issues", - "source": "https://github.com/utopia-php/cache/tree/0.13.0" + "source": "https://github.com/utopia-php/cache/tree/0.13.1" }, - "time": "2025-04-17T04:20:26+00:00" + "time": "2025-05-09T14:43:52+00:00" }, { "name": "utopia-php/compression", @@ -2164,16 +2164,16 @@ }, { "name": "laravel/pint", - "version": "v1.22.0", + "version": "v1.22.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", - "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "url": "https://api.github.com/repos/laravel/pint/zipball/941d1927c5ca420c22710e98420287169c7bcaf7", + "reference": "941d1927c5ca420c22710e98420287169c7bcaf7", "shasum": "" }, "require": { @@ -2185,11 +2185,11 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75.0", - "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.3.1", + "illuminate/view": "^11.44.7", + "larastan/larastan": "^3.4.0", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -2226,7 +2226,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-04-08T22:11:45+00:00" + "time": "2025-05-08T08:38:12+00:00" }, { "name": "myclabs/deep-copy", @@ -4131,7 +4131,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { @@ -4139,6 +4139,6 @@ "ext-pdo": "*", "ext-mbstring": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.6.0" } diff --git a/phpunit.xml b/phpunit.xml index ccdaa969e..8ba994793 100755 --- a/phpunit.xml +++ b/phpunit.xml @@ -16,14 +16,4 @@ ./tests/e2e/Adapter - - - ./src/ - ./tests/ - - - - - - \ No newline at end of file diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 9b25dae55..89ae9feb6 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -536,6 +536,17 @@ abstract public function analyzeCollection(string $collection): bool; */ abstract public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool; + /** + * Create Attributes + * + * @param string $collection + * @param array> $attributes + * @return bool + * @throws TimeoutException + * @throws DuplicateException + */ + abstract public function createAttributes(string $collection, array $attributes): bool; + /** * Update Attribute * @@ -988,6 +999,13 @@ abstract public function getSupportForReconnection(): bool; */ abstract public function getSupportForHostname(): bool; + /** + * Is creating multiple attributes in a single query supported? + * + * @return bool + */ + abstract public function getSupportForBatchCreateAttributes(): bool; + /** * Get current attribute count from collection document * @@ -1094,7 +1112,7 @@ public function filter(string $value): string return $value; } - public function escapeWildcards(string $value): string + protected function escapeWildcards(string $value): string { $wildcards = [ '%', diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 9b3d0c126..e9c639536 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -358,33 +358,51 @@ public function analyzeCollection(string $collection): bool } /** - * Create Attribute + * Get Schema Attributes * * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * @return bool - * @throws Exception - * @throws PDOException + * @return array + * @throws DatabaseException */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + public function getSchemaAttributes(string $collection): array { - $name = $this->filter($collection); - $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed, $array); - - $sql = "ALTER TABLE {$this->getSQLTable($name)} ADD COLUMN `{$id}` {$type};"; - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + $schema = $this->getDatabase(); + $collection = $this->getNamespace().'_'.$this->filter($collection); try { - return $this->getPDO() - ->prepare($sql) - ->execute(); + $stmt = $this->getPDO()->prepare(' + SELECT + COLUMN_NAME as _id, + COLUMN_DEFAULT as columnDefault, + IS_NULLABLE as isNullable, + DATA_TYPE as dataType, + CHARACTER_MAXIMUM_LENGTH as characterMaximumLength, + NUMERIC_PRECISION as numericPrecision, + NUMERIC_SCALE as numericScale, + DATETIME_PRECISION as datetimePrecision, + COLUMN_TYPE as columnType, + COLUMN_KEY as columnKey, + EXTRA as extra + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table + '); + $stmt->bindParam(':schema', $schema); + $stmt->bindParam(':table', $collection); + $stmt->execute(); + $results = $stmt->fetchAll(); + $stmt->closeCursor(); + + foreach ($results as $index => $document) { + $document['$id'] = $document['_id']; + unset($document['_id']); + + $results[$index] = new Document($document); + } + + return $results; + } catch (PDOException $e) { - throw $this->processException($e); + throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e); } } @@ -425,67 +443,6 @@ public function updateAttribute(string $collection, string $id, string $type, in } } - /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * @param bool $array - * @return bool - * @throws Exception - * @throws PDOException - */ - public function deleteAttribute(string $collection, string $id, bool $array = false): bool - { - $name = $this->filter($collection); - $id = $this->filter($id); - - $sql = "ALTER TABLE {$this->getSQLTable($name)} DROP COLUMN `{$id}`;"; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - if ($e->getCode() === "42000" && $e->errorInfo[1] === 1091) { - return true; - } - - throw $this->processException($e); - } - } - - /** - * Rename Attribute - * - * @param string $collection - * @param string $old - * @param string $new - * @return bool - * @throws Exception - * @throws PDOException - */ - public function renameAttribute(string $collection, string $old, string $new): bool - { - $collection = $this->filter($collection); - $old = $this->filter($old); - $new = $this->filter($new); - - $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME COLUMN `{$old}` TO `{$new}`;"; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); - - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); - } - } - /** * @param string $collection * @param string $id @@ -2206,6 +2163,11 @@ public function getSupportForUpserts(): bool return true; } + public function getSupportForSchemaAttributes(): bool + { + return true; + } + /** * Set max execution time * @param int $milliseconds @@ -2295,58 +2257,12 @@ protected function processException(PDOException $e): \Exception return new NotFoundException('Collection not found', $e->getCode(), $e); } - return $e; - } - - /** - * Get Schema Attributes - * - * @param string $collection - * @return array - * @throws DatabaseException - */ - public function getSchemaAttributes(string $collection): array - { - $schema = $this->getDatabase(); - $collection = $this->getNamespace().'_'.$this->filter($collection); - - try { - $stmt = $this->getPDO()->prepare(' - SELECT - COLUMN_NAME as columnName, - COLUMN_DEFAULT as columnDefault, - IS_NULLABLE as isNullable, - DATA_TYPE as dataType, - CHARACTER_MAXIMUM_LENGTH as characterMaximumLength, - NUMERIC_PRECISION as numericPrecision, - NUMERIC_SCALE as numericScale, - DATETIME_PRECISION as datetimePrecision, - COLUMN_TYPE as columnType, - COLUMN_KEY as columnKey, - EXTRA as extra - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table - '); - $stmt->bindParam(':schema', $schema); - $stmt->bindParam(':table', $collection); - $stmt->execute(); - $results = $stmt->fetchAll(); - $stmt->closeCursor(); - - foreach ($results as $index => $document) { - $results[$index] = new Document($document); - } - - return $results; - - } catch (PDOException $e) { - throw new DatabaseException('Failed to get schema attributes', $e->getCode(), $e); + // Unknown column + if ($e->getCode() === '42000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1091) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); } - } - public function getSupportForSchemaAttributes(): bool - { - return true; + return $e; } protected function quote(string $string): string diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 86521df0a..084bd2768 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -170,6 +170,11 @@ public function createAttribute(string $collection, string $id, string $type, in return $this->delegate(__FUNCTION__, \func_get_args()); } + public function createAttributes(string $collection, array $attributes): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function updateAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false, ?string $newKey = null): bool { return $this->delegate(__FUNCTION__, \func_get_args()); @@ -410,6 +415,11 @@ public function getSupportForHostname(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForBatchCreateAttributes(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getCountOfAttributes(Document $collection): int { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 5d94c4ad9..76adf39d3 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -10,6 +10,7 @@ use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Order as OrderException; use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; @@ -401,76 +402,6 @@ public function analyzeCollection(string $collection): bool return false; } - /** - * Create Attribute - * - * @param string $collection - * @param string $id - * @param string $type - * @param int $size - * @param bool $signed - * @param bool $array - * - * @return bool - * @throws DatabaseException - */ - public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool - { - $name = $this->filter($collection); - $id = $this->filter($id); - $type = $this->getSQLType($type, $size, $signed, $array); - - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - ADD COLUMN \"{$id}\" {$type} - "; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); - - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - throw $this->processException($e); - } - } - - /** - * Delete Attribute - * - * @param string $collection - * @param string $id - * @param bool $array - * - * @return bool - * @throws DatabaseException - */ - public function deleteAttribute(string $collection, string $id, bool $array = false): bool - { - $name = $this->filter($collection); - $id = $this->filter($id); - - $sql = " - ALTER TABLE {$this->getSQLTable($name)} - DROP COLUMN \"{$id}\"; - "; - - $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); - - try { - return $this->getPDO() - ->prepare($sql) - ->execute(); - } catch (PDOException $e) { - if ($e->getCode() === "42703" && $e->errorInfo[1] === 7) { - return true; - } - - throw $e; - } - } - /** * Rename Attribute * @@ -1996,17 +1927,6 @@ protected function getSQLSchema(): string return "\"{$this->getDatabase()}\"."; } - /** - * Get SQL table - * - * @param string $name - * @return string - */ - protected function getSQLTable(string $name): string - { - return "\"{$this->getDatabase()}\".\"{$this->getNamespace()}_{$name}\""; - } - /** * Get PDO Type * @@ -2148,6 +2068,11 @@ protected function processException(PDOException $e): \Exception return new TruncateException('Resize would result in data truncation', $e->getCode(), $e); } + // Unknown column + if ($e->getCode() === "42703" && isset($e->errorInfo[1]) && $e->errorInfo[1] === 7) { + return new NotFoundException('Attribute not found', $e->getCode(), $e); + } + return $e; } diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index c2907b88d..f88fec46f 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -200,6 +200,126 @@ public function list(): array return []; } + /** + * Create Attribute + * + * @param string $collection + * @param string $id + * @param string $type + * @param int $size + * @param bool $signed + * @param bool $array + * @return bool + * @throws Exception + * @throws PDOException + */ + public function createAttribute(string $collection, string $id, string $type, int $size, bool $signed = true, bool $array = false): bool + { + $id = $this->quote($this->filter($id)); + $type = $this->getSQLType($type, $size, $signed, $array); + + $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$id} {$type};"; + $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + + try { + return $this->getPDO() + ->prepare($sql) + ->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + } + + /** + * Create Attributes + * + * @param string $collection + * @param array> $attributes + * @return bool + * @throws DatabaseException + */ + public function createAttributes(string $collection, array $attributes): bool + { + $parts = []; + foreach ($attributes as $attribute) { + $id = $this->quote($this->filter($attribute['$id'])); + $type = $this->getSQLType( + $attribute['type'], + $attribute['size'], + $attribute['signed'] ?? true, + $attribute['array'] ?? false, + ); + $parts[] = "{$id} {$type}"; + } + + $columns = \implode(', ADD COLUMN ', $parts); + + $sql = "ALTER TABLE {$this->getSQLTable($collection)} ADD COLUMN {$columns};"; + $sql = $this->trigger(Database::EVENT_ATTRIBUTE_CREATE, $sql); + + try { + return $this->getPDO() + ->prepare($sql) + ->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + } + + /** + * Rename Attribute + * + * @param string $collection + * @param string $old + * @param string $new + * @return bool + * @throws Exception + * @throws PDOException + */ + public function renameAttribute(string $collection, string $old, string $new): bool + { + $collection = $this->filter($collection); + $old = $this->quote($this->filter($old)); + $new = $this->quote($this->filter($new)); + + $sql = "ALTER TABLE {$this->getSQLTable($collection)} RENAME COLUMN {$old} TO {$new};"; + + $sql = $this->trigger(Database::EVENT_ATTRIBUTE_UPDATE, $sql); + + try { + return $this->getPDO() + ->prepare($sql) + ->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + } + + /** + * Delete Attribute + * + * @param string $collection + * @param string $id + * @param bool $array + * @return bool + * @throws Exception + * @throws PDOException + */ + public function deleteAttribute(string $collection, string $id, bool $array = false): bool + { + $id = $this->quote($this->filter($id)); + $sql = "ALTER TABLE {$this->getSQLTable($collection)} DROP COLUMN {$id};"; + $sql = $this->trigger(Database::EVENT_ATTRIBUTE_DELETE, $sql); + + try { + return $this->getPDO() + ->prepare($sql) + ->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + } + /** * Get Document * @@ -1261,6 +1381,11 @@ public function getSupportForReconnection(): bool return true; } + public function getSupportForBatchCreateAttributes(): bool + { + return true; + } + /** * @param string $value * @return string @@ -1324,16 +1449,12 @@ protected function getSQLOperator(string $method): string } } - public function escapeWildcards(string $value): string - { - $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; - - foreach ($wildcards as $wildcard) { - $value = \str_replace($wildcard, "\\$wildcard", $value); - } - - return $value; - } + abstract protected function getSQLType( + string $type, + int $size, + bool $signed = true, + bool $array = false + ): string; /** * Get SQL Index Type @@ -1616,6 +1737,17 @@ protected function getInternalKeyForAttribute(string $attribute): string }; } + protected function escapeWildcards(string $value): string + { + $wildcards = ['%', '_', '[', ']', '^', '-', '.', '*', '+', '?', '(', ')', '{', '}', '|']; + + foreach ($wildcards as $wildcard) { + $value = \str_replace($wildcard, "\\$wildcard", $value); + } + + return $value; + } + protected function processException(PDOException $e): \Exception { return $e; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index e9a7ecabc..dc1cbd900 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -948,6 +948,16 @@ public function getSupportForHostname(): bool return false; } + /** + * Is batch create attributes supported? + * + * @return bool + */ + public function getSupportForBatchCreateAttributes(): bool + { + return false; + } + /** * Get SQL Index Type * @@ -1049,7 +1059,7 @@ protected function getSQLPermissionsCondition(string $collection, array $roles, */ protected function getSQLTable(string $name): string { - return "`{$this->getNamespace()}_{$name}`"; + return $this->quote("{$this->getNamespace()}_{$this->filter($name)}"); } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index 9480ceb5e..6e2295185 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -136,6 +136,7 @@ class Database public const EVENT_PERMISSIONS_DELETE = 'permissions_delete'; public const EVENT_ATTRIBUTE_CREATE = 'attribute_create'; + public const EVENT_ATTRIBUTES_CREATE = 'attributes_create'; public const EVENT_ATTRIBUTE_UPDATE = 'attribute_update'; public const EVENT_ATTRIBUTE_DELETE = 'attribute_delete'; @@ -1544,12 +1545,206 @@ public function createAttribute(string $collection, string $id, string $type, in throw new NotFoundException('Collection not found'); } + $attribute = $this->validateAttribute( + $collection, + $id, + $type, + $size, + $required, + $default, + $signed, + $array, + $format, + $formatOptions, + $filters + ); + + $collection->setAttribute( + 'attributes', + $attribute, + Document::SET_TYPE_APPEND + ); + + try { + $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array); + + if (!$created) { + throw new DatabaseException('Failed to create attribute'); + } + } catch (DuplicateException $e) { + // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. + if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { + throw $e; + } + } + + if ($collection->getId() !== self::METADATA) { + $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + } + + $this->purgeCachedCollection($collection->getId()); + $this->purgeCachedDocument(self::METADATA, $collection->getId()); + + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); + + return true; + } + + /** + * Create Attribute + * + * @param string $collection + * @param array> $attributes + * @return bool + * @throws AuthorizationException + * @throws ConflictException + * @throws DatabaseException + * @throws DuplicateException + * @throws LimitException + * @throws StructureException + * @throws Exception + */ + public function createAttributes(string $collection, array $attributes): bool + { + if (empty($attributes)) { + throw new DatabaseException('No attributes to create'); + } + + $collection = $this->silent(fn () => $this->getCollection($collection)); + + if ($collection->isEmpty()) { + throw new NotFoundException('Collection not found'); + } + + $attributeDocuments = []; + foreach ($attributes as $attribute) { + if (!isset($attribute['$id'])) { + throw new DatabaseException('Missing attribute key'); + } + if (!isset($attribute['type'])) { + throw new DatabaseException('Missing attribute type'); + } + if (!isset($attribute['size'])) { + throw new DatabaseException('Missing attribute size'); + } + if (!isset($attribute['required'])) { + throw new DatabaseException('Missing attribute required'); + } + if (!isset($attribute['default'])) { + $attribute['default'] = null; + } + if (!isset($attribute['signed'])) { + $attribute['signed'] = true; + } + if (!isset($attribute['array'])) { + $attribute['array'] = false; + } + if (!isset($attribute['format'])) { + $attribute['format'] = null; + } + if (!isset($attribute['formatOptions'])) { + $attribute['formatOptions'] = []; + } + if (!isset($attribute['filters'])) { + $attribute['filters'] = []; + } + + $attributeDocument = $this->validateAttribute( + $collection, + $attribute['$id'], + $attribute['type'], + $attribute['size'], + $attribute['required'], + $attribute['default'], + $attribute['signed'], + $attribute['array'], + $attribute['format'], + $attribute['formatOptions'], + $attribute['filters'] + ); + + $collection->setAttribute( + 'attributes', + $attributeDocument, + Document::SET_TYPE_APPEND + ); + + $attributeDocuments[] = $attributeDocument; + } + + try { + $created = $this->adapter->createAttributes($collection->getId(), $attributes); + + if (!$created) { + throw new DatabaseException('Failed to create attributes'); + } + } catch (DuplicateException $e) { + // No attributes were in a metadata, but at least one of them was present on the table + // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. + if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { + throw $e; + } + } + + if ($collection->getId() !== self::METADATA) { + $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); + } + + $this->purgeCachedCollection($collection->getId()); + $this->purgeCachedDocument(self::METADATA, $collection->getId()); + + $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attributeDocuments); + + return true; + } + + /** + * @param Document $collection + * @param string $id + * @param string $type + * @param int $size + * @param bool $required + * @param mixed $default + * @param bool $signed + * @param bool $array + * @param string $format + * @param array $formatOptions + * @param array $filters + * @return Document + * @throws DuplicateException + * @throws LimitException + * @throws Exception + */ + private function validateAttribute( + Document $collection, + string $id, + string $type, + int $size, + bool $required, + mixed $default, + bool $signed, + bool $array, + ?string $format, + array $formatOptions, + array $filters + ): Document { // Attribute IDs are case-insensitive $attributes = $collection->getAttribute('attributes', []); + /** @var array $attributes */ foreach ($attributes as $attribute) { if (\strtolower($attribute->getId()) === \strtolower($id)) { - throw new DuplicateException('Attribute already exists'); + throw new DuplicateException('Attribute already exists in metadata'); + } + } + + if ($this->adapter->getSupportForSchemaAttributes() && !($this->getSharedTables() && $this->isMigrating())) { + $schema = $this->getSchemaAttributes($collection->getId()); + foreach ($schema as $attribute) { + $newId = $this->adapter->filter($attribute->getId()); + if (\strtolower($newId) === \strtolower($id)) { + throw new DuplicateException('Attribute already exists in schema'); + } } } @@ -1579,12 +1774,6 @@ public function createAttribute(string $collection, string $id, string $type, in $this->checkAttribute($collection, $attribute); - $collection->setAttribute( - 'attributes', - $attribute, - Document::SET_TYPE_APPEND - ); - switch ($type) { case self::VAR_STRING: if ($size > $this->adapter->getLimitForString()) { @@ -1615,29 +1804,7 @@ public function createAttribute(string $collection, string $id, string $type, in $this->validateDefaultTypes($type, $default); } - try { - $created = $this->adapter->createAttribute($collection->getId(), $id, $type, $size, $signed, $array); - - if (!$created) { - throw new DatabaseException('Failed to create attribute'); - } - } catch (DuplicateException $e) { - // HACK: Metadata should still be updated, can be removed when null tenant collections are supported. - if (!$this->adapter->getSharedTables() || !$this->isMigrating()) { - throw $e; - } - } - - if ($collection->getId() !== self::METADATA) { - $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); - } - - $this->purgeCachedCollection($collection->getId()); - $this->purgeCachedDocument(self::METADATA, $collection->getId()); - - $this->trigger(self::EVENT_ATTRIBUTE_CREATE, $attribute); - - return true; + return $attribute; } /** @@ -2130,10 +2297,12 @@ public function deleteAttribute(string $collection, string $id): bool } } - $deleted = $this->adapter->deleteAttribute($collection->getId(), $id); - - if (!$deleted) { - throw new DatabaseException('Failed to delete attribute'); + try { + if (!$this->adapter->deleteAttribute($collection->getId(), $id)) { + throw new DatabaseException('Failed to delete attribute'); + } + } catch (NotFoundException) { + // Ignore } $collection->setAttribute('attributes', \array_values($attributes)); diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 2a8ad4dbe..168a227ee 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -356,6 +356,40 @@ public function createAttribute(string $collection, string $id, string $type, in return $result; } + public function createAttributes(string $collection, array $attributes): bool + { + $result = $this->source->createAttributes($collection, $attributes); + + if ($this->destination === null) { + return $result; + } + + try { + foreach ($attributes as &$attribute) { + foreach ($this->writeFilters as $filter) { + $document = $filter->beforeCreateAttribute( + source: $this->source, + destination: $this->destination, + collectionId: $collection, + attributeId: $attribute['$id'], + attribute: new Document($attribute), + ); + + $attribute = $document->getArrayCopy(); + } + } + + $result = $this->destination->createAttributes( + $collection, + $attributes, + ); + } catch (\Throwable $err) { + $this->logError('createAttributes', $err); + } + + return $result; + } + public function updateAttribute(string $collection, string $id, ?string $type = null, ?int $size = null, ?bool $required = null, mixed $default = null, ?bool $signed = null, ?bool $array = null, ?string $format = null, ?array $formatOptions = null, ?array $filters = null, ?string $newKey = null): Document { $document = $this->source->updateAttribute( diff --git a/tests/e2e/Adapter/Scopes/AttributeTests.php b/tests/e2e/Adapter/Scopes/AttributeTests.php index cab177400..18876766d 100644 --- a/tests/e2e/Adapter/Scopes/AttributeTests.php +++ b/tests/e2e/Adapter/Scopes/AttributeTests.php @@ -7,6 +7,7 @@ use Utopia\Database\Database; use Utopia\Database\DateTime; use Utopia\Database\Document; +use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Exception\Conflict as ConflictException; use Utopia\Database\Exception\Dependency as DependencyException; @@ -26,10 +27,11 @@ trait AttributeTests { - public function createRandomString(int $length = 10): string + private function createRandomString(int $length = 10): string { return \substr(\bin2hex(\random_bytes(\max(1, \intval(($length + 1) / 2)))), 0, $length); } + /** * Using phpunit dataProviders to check that all these combinations of types/defaults throw exceptions * https://phpunit.de/manual/3.7/en/writing-tests-for-phpunit.html#writing-tests-for-phpunit.data-providers @@ -1628,4 +1630,369 @@ public function testUnknownFormat(): void $this->expectException(\Exception::class); $this->assertEquals(false, static::getDatabase()->createAttribute('attributes', 'bad_format', Database::VAR_STRING, 256, true, null, true, false, 'url')); } + + + // Bulk attribute creation tests + public function testCreateAttributesEmpty(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + try { + static::getDatabase()->createAttributes(__FUNCTION__, []); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesMissingId(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => false + ]]; + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesMissingType(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'size' => 10, + 'required' => false + ]]; + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesMissingSize(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_STRING, + 'required' => false + ]]; + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesMissingRequired(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_STRING, + 'size' => 10 + ]]; + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesDuplicateMetadata(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + static::getDatabase()->createAttribute(__FUNCTION__, 'dup', Database::VAR_STRING, 10, false); + + $attributes = [[ + '$id' => 'dup', + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => false + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DuplicateException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DuplicateException::class, $e); + } + } + + public function testCreateAttributesInvalidFilter(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'date', + 'type' => Database::VAR_DATETIME, + 'size' => 0, + 'required' => false, + 'filters' => [] + ]]; + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesInvalidFormat(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => false, + 'format' => 'nonexistent' + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesDefaultOnRequired(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => true, + 'default' => 'bar' + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesUnknownType(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [[ + '$id' => 'foo', + 'type' => 'unknown', + 'size' => 0, + 'required' => false + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesStringSizeLimit(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $max = static::getDatabase()->getAdapter()->getLimitForString(); + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_STRING, + 'size' => $max + 1, + 'required' => false + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesIntegerSizeLimit(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $limit = static::getDatabase()->getAdapter()->getLimitForInt() / 2; + + $attributes = [[ + '$id' => 'foo', + 'type' => Database::VAR_INTEGER, + 'size' => (int)$limit + 1, + 'required' => false + ]]; + + try { + static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->fail('Expected DatabaseException not thrown'); + } catch (\Throwable $e) { + $this->assertInstanceOf(DatabaseException::class, $e); + } + } + + public function testCreateAttributesSuccessMultiple(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [ + [ + '$id' => 'a', + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => false + ], + [ + '$id' => 'b', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => false + ], + ]; + + $result = static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->assertTrue($result); + + $collection = static::getDatabase()->getCollection(__FUNCTION__); + $attrs = $collection->getAttribute('attributes'); + $this->assertCount(2, $attrs); + $this->assertEquals('a', $attrs[0]['$id']); + $this->assertEquals('b', $attrs[1]['$id']); + + $doc = static::getDatabase()->createDocument(__FUNCTION__, new Document([ + 'a' => 'foo', + 'b' => 123, + ])); + + $this->assertEquals('foo', $doc->getAttribute('a')); + $this->assertEquals(123, $doc->getAttribute('b')); + } + + public function testCreateAttributesDelete(): void + { + if (!static::getDatabase()->getAdapter()->getSupportForBatchCreateAttributes()) { + $this->expectNotToPerformAssertions(); + return; + } + + static::getDatabase()->createCollection(__FUNCTION__); + + $attributes = [ + [ + '$id' => 'a', + 'type' => Database::VAR_STRING, + 'size' => 10, + 'required' => false + ], + [ + '$id' => 'b', + 'type' => Database::VAR_INTEGER, + 'size' => 0, + 'required' => false + ], + ]; + + $result = static::getDatabase()->createAttributes(__FUNCTION__, $attributes); + $this->assertTrue($result); + + $collection = static::getDatabase()->getCollection(__FUNCTION__); + $attrs = $collection->getAttribute('attributes'); + $this->assertCount(2, $attrs); + $this->assertEquals('a', $attrs[0]['$id']); + $this->assertEquals('b', $attrs[1]['$id']); + + static::getDatabase()->deleteAttribute(__FUNCTION__, 'a'); + + $collection = static::getDatabase()->getCollection(__FUNCTION__); + $attrs = $collection->getAttribute('attributes'); + $this->assertCount(1, $attrs); + $this->assertEquals('b', $attrs[0]['$id']); + } } diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index 6f8762feb..3650ab837 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -506,31 +506,32 @@ public function testSchemaAttributes(): void /** * @var Document $attribute */ - $attributes[$attribute->getAttribute('columnName')] = $attribute; + + $attributes[$attribute->getId()] = $attribute; } $attribute = $attributes['username']; - $this->assertEquals('username', $attribute['columnName']); + $this->assertEquals('username', $attribute['$id']); $this->assertEquals('varchar', $attribute['dataType']); $this->assertEquals('varchar(128)', $attribute['columnType']); $this->assertEquals('128', $attribute['characterMaximumLength']); $this->assertEquals('YES', $attribute['isNullable']); $attribute = $attributes['story']; - $this->assertEquals('story', $attribute['columnName']); + $this->assertEquals('story', $attribute['$id']); $this->assertEquals('text', $attribute['dataType']); $this->assertEquals('text', $attribute['columnType']); $this->assertEquals('65535', $attribute['characterMaximumLength']); $attribute = $attributes['string_list']; - $this->assertEquals('string_list', $attribute['columnName']); + $this->assertEquals('string_list', $attribute['$id']); $this->assertTrue(in_array($attribute['dataType'], ['json', 'longtext'])); // mysql vs maria $this->assertTrue(in_array($attribute['columnType'], ['json', 'longtext'])); $this->assertTrue(in_array($attribute['characterMaximumLength'], [null, '4294967295'])); $this->assertEquals('YES', $attribute['isNullable']); $attribute = $attributes['dob']; - $this->assertEquals('dob', $attribute['columnName']); + $this->assertEquals('dob', $attribute['$id']); $this->assertEquals('datetime', $attribute['dataType']); $this->assertEquals('datetime(3)', $attribute['columnType']); $this->assertEquals(null, $attribute['characterMaximumLength']); @@ -538,7 +539,7 @@ public function testSchemaAttributes(): void if ($db->getSharedTables()) { $attribute = $attributes['_tenant']; - $this->assertEquals('_tenant', $attribute['columnName']); + $this->assertEquals('_tenant', $attribute['$id']); $this->assertEquals('int', $attribute['dataType']); $this->assertEquals('10', $attribute['numericPrecision']); $this->assertTrue(in_array($attribute['columnType'], ['int unsigned', 'int(11) unsigned'])); @@ -784,7 +785,7 @@ public function testKeywords(): void // Attribute name tests foreach ($keywords as $keyword) { - $collectionName = 'rk' . $keyword; // rk is short-hand for reserved-keyword. We do this sicne there are some limits (64 chars max) + $collectionName = 'rk' . $keyword; // rk is shorthand for reserved-keyword. We do this since there are some limits (64 chars max) $collection = $database->createCollection($collectionName); $this->assertEquals($collectionName, $collection->getId());