diff --git a/composer.json b/composer.json index ad3bf6b8b..400b09beb 100755 --- a/composer.json +++ b/composer.json @@ -35,11 +35,12 @@ "require": { "php": ">=8.1", "ext-pdo": "*", + "ext-mongodb": "*", "ext-mbstring": "*", "utopia-php/framework": "0.33.*", "utopia-php/cache": "0.13.*", "utopia-php/pools": "0.8.*", - "utopia-php/mongo": "0.7.*" + "utopia-php/mongo": "0.8.*" }, "require-dev": { "fakerphp/faker": "1.23.*", diff --git a/composer.lock b/composer.lock index 900dc7774..4c466d536 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0d7b7b4e8299046f6d9881d12a27708d", + "content-hash": "72500d7986c2f49968424504aafb795d", "packages": [ { "name": "brick/math", @@ -189,16 +189,16 @@ }, { "name": "mongodb/mongodb", - "version": "2.1.0", + "version": "2.1.1", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "3bbe7ba9578724c7e1f47fcd17c881c0995baaad" + "reference": "f399d24905dd42f97dfe0af9706129743ef247ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/3bbe7ba9578724c7e1f47fcd17c881c0995baaad", - "reference": "3bbe7ba9578724c7e1f47fcd17c881c0995baaad", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/f399d24905dd42f97dfe0af9706129743ef247ac", + "reference": "f399d24905dd42f97dfe0af9706129743ef247ac", "shasum": "" }, "require": { @@ -260,9 +260,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.0" + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.1" }, - "time": "2025-05-23T10:48:05+00:00" + "time": "2025-08-13T20:50:05+00:00" }, { "name": "nyholm/psr7", @@ -2166,30 +2166,30 @@ }, { "name": "utopia-php/mongo", - "version": "0.7.0", + "version": "0.8.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "1363598f9f8e6c066f5821704be95e3e24ea66aa" + "reference": "b9c9c2e3f693edfdfcff777a68b3cd0d3e5bb97d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/1363598f9f8e6c066f5821704be95e3e24ea66aa", - "reference": "1363598f9f8e6c066f5821704be95e3e24ea66aa", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/b9c9c2e3f693edfdfcff777a68b3cd0d3e5bb97d", + "reference": "b9c9c2e3f693edfdfcff777a68b3cd0d3e5bb97d", "shasum": "" }, "require": { - "ext-mongodb": "2.1.1", - "mongodb/mongodb": "2.1.0", + "ext-mongodb": "2.1.*", + "mongodb/mongodb": "2.1.*", "php": ">=8.0", - "ramsey/uuid": "^4.9.0" + "ramsey/uuid": "4.9.*" }, "require-dev": { - "fakerphp/faker": "^1.14", - "laravel/pint": "1.2.*", - "phpstan/phpstan": "2.1.*", - "phpunit/phpunit": "^9.4", - "swoole/ide-helper": "4.8.0" + "fakerphp/faker": "1.*", + "laravel/pint": "*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "9.*", + "swoole/ide-helper": "5.1.*" }, "type": "library", "autoload": { @@ -2221,9 +2221,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/0.7.0" + "source": "https://github.com/utopia-php/mongo/tree/0.8.0" }, - "time": "2025-09-26T09:15:55+00:00" + "time": "2025-09-30T09:36:04+00:00" }, { "name": "utopia-php/pools", @@ -4479,14 +4479,15 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.1", "ext-pdo": "*", + "ext-mongodb": "*", "ext-mbstring": "*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index e2c9909de..27fb5d2a5 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -3,7 +3,6 @@ namespace Utopia\Database\Adapter; use Exception; -use MongoDB\BSON\Int64; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use Utopia\Database\Adapter; @@ -12,8 +11,9 @@ use Utopia\Database\DateTime; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Duplicate; -use Utopia\Database\Exception\Timeout; +use Utopia\Database\Exception\Duplicate as DuplicateException; +use Utopia\Database\Exception\Timeout as TimeoutException; +use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Mongo\Client; @@ -50,15 +50,12 @@ class Mongo extends Adapter */ private const DEFAULT_BATCH_SIZE = 1000; - //protected ?int $timeout = null; - /** * Transaction/session state for MongoDB transactions + * @var array|null $session */ - private ?object $sessionId = null; // Store raw BSON id object - private ?int $txnNumber = null; + private ?array $session = null; // Store session array from startSession protected int $inTransaction = 0; - private bool $firstOpInTransaction = false; /** * Constructor. @@ -132,25 +129,10 @@ public function startTransaction(): bool try { if ($this->inTransaction === 0) { - if (!$this->sessionId) { - $this->sessionId = $this->client->startSession(); // Store raw id object + if (!$this->session) { + $this->session = $this->client->startSession(); // Get session array + $this->client->startTransaction($this->session); // Start the transaction } - $this->txnNumber = ($this->txnNumber ?? 0) + 1; - $this->firstOpInTransaction = true; - - // Initialize the transaction on MongoDB's side with a dummy find operation - // This ensures the transaction is active even if validation fails later. - $this->client->query([ - 'find' => 'system.version', - 'filter' => $this->client->toObject([]), - 'limit' => 1, - 'lsid' => ['id' => $this->sessionId], - 'txnNumber' => new \MongoDB\BSON\Int64($this->txnNumber), // Long type for txnNumber - 'autocommit' => false, - 'startTransaction' => true - ], 'admin'); - - $this->firstOpInTransaction = false; } $this->inTransaction++; return true; @@ -172,21 +154,25 @@ public function commitTransaction(): bool } $this->inTransaction--; if ($this->inTransaction === 0) { - if (!$this->sessionId) { + if (!$this->session) { return false; } try { - $result = $this->client->commitTransaction( - ['id' => $this->sessionId], - $this->txnNumber - ); + $result = $this->client->commitTransaction($this->session); + } catch (MongoException $e) { + // If there's no active transaction, it may have been auto-aborted due to an error. + // This is not necessarily a failure, just return success since the transaction was already terminated. + $e = $this->processException($e); + if ($e instanceof TransactionException) { + $this->session = null; + return true; + } } catch (\Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } // Session is now closed by the client using endSessions, state is reset - $this->sessionId = null; - $this->txnNumber = null; + $this->session = null; return true; } @@ -209,22 +195,18 @@ public function rollbackTransaction(): bool } $this->inTransaction--; if ($this->inTransaction === 0) { - if (!$this->sessionId) { + if (!$this->session) { return false; } try { - $result = $this->client->abortTransaction( - ['id' => $this->sessionId], // Pass raw id object - $this->txnNumber - ); + $result = $this->client->abortTransaction($this->session); } catch (\Throwable $e) { throw new DatabaseException($e->getMessage(), $e->getCode(), $e); } // Session is now closed by the client using endSessions, reset our state - $this->sessionId = null; - $this->txnNumber = null; + $this->session = null; return true; } @@ -242,16 +224,9 @@ public function rollbackTransaction(): bool */ private function getTransactionOptions(array $options = []): array { - if ($this->inTransaction) { - $options['lsid'] = ['id' => $this->sessionId]; - $options['txnNumber'] = new Int64($this->txnNumber); - $options['autocommit'] = false; - - if ($this->firstOpInTransaction) { - // For MongoDB, the first operation in a transaction should include startTransaction - $options['startTransaction'] = true; - $this->firstOpInTransaction = false; - } + if ($this->inTransaction && $this->session) { + // Pass the session array directly - the client will handle the transaction state internally + $options['session'] = $this->session; } return $options; } @@ -265,7 +240,10 @@ private function getTransactionOptions(array $options = []): array */ public function ping(): bool { - return $this->getClient()->query(['ping' => 1])->ok ?? false; + return $this->getClient()->query([ + 'ping' => 1, + 'skipReadConcern' => true + ])->ok ?? false; } public function reconnect(): void @@ -360,16 +338,22 @@ public function createCollection(string $name, array $attributes = [], array $in { $id = $this->getNamespace() . '_' . $this->filter($name); - if ($name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { + // For metadata collections outside transactions, check if exists first + if (!$this->inTransaction && $name === Database::METADATA && $this->exists($this->getNamespace(), $name)) { return true; } // Returns an array/object with the result document try { - $this->getClient()->createCollection($id); + $options = $this->getTransactionOptions(); + $this->getClient()->createCollection($id, $options); } catch (MongoException $e) { - throw $this->processException($e); + $processed = $this->processException($e); + if ($processed instanceof DuplicateException) { + return true; + } + throw $processed; } $internalIndex = [ @@ -404,7 +388,8 @@ public function createCollection(string $name, array $attributes = [], array $in } try { - $indexesCreated = $this->client->createIndexes($id, $internalIndex); + $options = $this->getTransactionOptions(); + $indexesCreated = $this->client->createIndexes($id, $internalIndex, $options); } catch (\Exception $e) { throw $this->processException($e); } @@ -493,7 +478,8 @@ public function createCollection(string $name, array $attributes = [], array $in try { - $indexesCreated = $this->getClient()->createIndexes($id, $newIndexes); + $options = $this->getTransactionOptions(); + $indexesCreated = $this->getClient()->createIndexes($id, $newIndexes, $options); } catch (\Exception $e) { throw $this->processException($e); } @@ -516,6 +502,8 @@ public function listCollections(): array { $list = []; + // Note: listCollections is a metadata operation that should not run in transactions + // to avoid transaction conflicts and readConcern issues foreach ((array)$this->getClient()->listCollectionNames() as $value) { $list[] = $value; } @@ -1149,7 +1137,7 @@ public function castingBefore(Document $collection, Document $document): Documen foreach ($value as &$node) { switch ($type) { - case Database::VAR_DATETIME : + case Database::VAR_DATETIME: if (!($node instanceof UTCDateTime)) { $node = new UTCDateTime(new \DateTime($node)); } @@ -1173,7 +1161,8 @@ public function castingBefore(Document $collection, Document $document): Documen * * @return array * - * @throws Duplicate + * @throws DuplicateException + * @throws DatabaseException */ public function createDocuments(Document $collection, array $documents): array { @@ -1221,7 +1210,8 @@ public function createDocuments(Document $collection, array $documents): array * @param array $options * * @return array - * @throws Duplicate + * @throws DuplicateException + * @throws Exception */ private function insertDocument(string $name, array $document, array $options = []): array { @@ -1258,8 +1248,8 @@ private function insertDocument(string $name, array $document, array $options = * @param Document $document * @param bool $skipPermissions * @return Document + * @throws DuplicateException * @throws DatabaseException - * @throws Duplicate */ public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { @@ -1676,7 +1666,7 @@ protected function getInternalKeyForAttribute(string $attribute): string * * @return array * @throws Exception - * @throws Timeout + * @throws TimeoutException */ public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array { @@ -2806,30 +2796,34 @@ public function getKeywords(): array protected function processException(Exception $e): \Exception { - // Timeout if ($e->getCode() === 50) { - return new Timeout('Query timed out', $e->getCode(), $e); + return new TimeoutException('Query timed out', $e->getCode(), $e); } - // Duplicate key error (MongoDB error code 11000) + // Duplicate key error if ($e->getCode() === 11000) { - return new Duplicate('Document already exists', $e->getCode(), $e); + return new DuplicateException('Document already exists', $e->getCode(), $e); } - // Duplicate key error for unique index (MongoDB error code 11001) + // Duplicate key error for unique index if ($e->getCode() === 11001) { - return new Duplicate('Document already exists', $e->getCode(), $e); + return new DuplicateException('Document already exists', $e->getCode(), $e); } - // Collection already exists (MongoDB error code 48) + // Collection already exists if ($e->getCode() === 48) { - return new Duplicate('Collection already exists', $e->getCode(), $e); + return new DuplicateException('Collection already exists', $e->getCode(), $e); } - // Index already exists (MongoDB error code 85) + // Index already exists if ($e->getCode() === 85) { - return new Duplicate('Index already exists', $e->getCode(), $e); + return new DuplicateException('Index already exists', $e->getCode(), $e); + } + + // No transaction + if ($e->getCode() === 251) { + return new TransactionException('No active transaction', $e->getCode(), $e); } return $e;