From 4d550457aced44e3d107906d985e2b313c32d941 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 5 Mar 2025 12:31:15 +1300 Subject: [PATCH 1/3] Add internal auto-reconnect using PDO wrapper --- src/Database/PDO.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Database/PDO.php b/src/Database/PDO.php index a68b02121..fb3e34395 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -2,6 +2,8 @@ namespace Utopia\Database; +use Swoole\Database\DetectsLostConnections; + /** * A PDO wrapper that forwards method calls to the internal PDO instance. * @@ -35,10 +37,20 @@ public function __construct( * @param string $method * @param array $args * @return mixed + * @throws \Throwable */ public function __call(string $method, array $args): mixed { - return $this->pdo->{$method}(...$args); + try { + return $this->pdo->{$method}(...$args); + } catch (\Throwable $e) { + if (DetectsLostConnections::causedByLostConnection($e)) { + $this->reconnect(); + return $this->pdo->{$method}(...$args); + } + + throw $e; + } } /** From d269729377c5fa1bfe627b03eb26fca9759be902 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 5 Mar 2025 12:31:51 +1300 Subject: [PATCH 2/3] Add wrapper unit tests --- tests/unit/PDOTest.php | 152 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/unit/PDOTest.php diff --git a/tests/unit/PDOTest.php b/tests/unit/PDOTest.php new file mode 100644 index 000000000..5d343e39c --- /dev/null +++ b/tests/unit/PDOTest.php @@ -0,0 +1,152 @@ +getProperty('pdo'); + $pdoProperty->setAccessible(true); + + // Create a mock for the internal \PDO object. + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + + // Create a PDOStatement mock since query returns a PDOStatement + $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + + // Expect that when we call 'query', the mock returns our PDOStatement mock. + $pdoMock->expects($this->once()) + ->method('query') + ->with('SELECT 1') + ->willReturn($pdoStatementMock); + + $pdoProperty->setValue($pdoWrapper, $pdoMock); + + $result = $pdoWrapper->query('SELECT 1'); + + $this->assertSame($pdoStatementMock, $result); + } + + public function testLostConnectionRetriesCall() + { + $dsn = 'sqlite::memory:'; + $pdoWrapper = $this->getMockBuilder(PDO::class) + ->setConstructorArgs([$dsn, null, null, []]) + ->onlyMethods(['reconnect']) + ->getMock(); + + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + + $pdoMock->expects($this->exactly(2)) + ->method('query') + ->with('SELECT 1') + ->will($this->onConsecutiveCalls( + $this->throwException(new \Exception("Lost connection")), + $pdoStatementMock + )); + + $reflection = new ReflectionClass($pdoWrapper); + $pdoProperty = $reflection->getProperty('pdo'); + $pdoProperty->setAccessible(true); + $pdoProperty->setValue($pdoWrapper, $pdoMock); + + $pdoWrapper->expects($this->once()) + ->method('reconnect') + ->willReturnCallback(function () use ($pdoWrapper, $pdoMock, $pdoProperty) { + $pdoProperty->setValue($pdoWrapper, $pdoMock); + }); + + $result = $pdoWrapper->query('SELECT 1'); + + $this->assertSame($pdoStatementMock, $result); + } + + public function testNonLostConnectionExceptionIsRethrown() + { + $dsn = 'sqlite::memory:'; + $pdoWrapper = new PDO($dsn, null, null); + + $reflection = new ReflectionClass($pdoWrapper); + $pdoProperty = $reflection->getProperty('pdo'); + $pdoProperty->setAccessible(true); + + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + + $pdoMock->expects($this->once()) + ->method('query') + ->with('SELECT 1') + ->will($this->throwException(new \Exception("Other error"))); + + $pdoProperty->setValue($pdoWrapper, $pdoMock); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Other error"); + + $pdoWrapper->query('SELECT 1'); + } + + public function testReconnectCreatesNewPDOInstance() + { + $dsn = 'sqlite::memory:'; + $pdoWrapper = new PDO($dsn, null, null); + + $reflection = new ReflectionClass($pdoWrapper); + $pdoProperty = $reflection->getProperty('pdo'); + $pdoProperty->setAccessible(true); + + $oldPDO = $pdoProperty->getValue($pdoWrapper); + $pdoWrapper->reconnect(); + $newPDO = $pdoProperty->getValue($pdoWrapper); + + $this->assertNotSame($oldPDO, $newPDO, "Reconnect should create a new PDO instance"); + } + + public function testMethodCallForPrepare() + { + $dsn = 'sqlite::memory:'; + $pdoWrapper = new PDO($dsn, null, null); + + $reflection = new ReflectionClass($pdoWrapper); + $pdoProperty = $reflection->getProperty('pdo'); + $pdoProperty->setAccessible(true); + + $pdoMock = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdoStatementMock = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + + $pdoMock->expects($this->once()) + ->method('prepare') + ->with('SELECT * FROM table', [\PDO::ATTR_CURSOR => \PDO::CURSOR_FWDONLY]) + ->willReturn($pdoStatementMock); + + $pdoProperty->setValue($pdoWrapper, $pdoMock); + + $result = $pdoWrapper->prepare('SELECT * FROM table', [\PDO::ATTR_CURSOR => \PDO::CURSOR_FWDONLY]); + + $this->assertSame($pdoStatementMock, $result); + } +} \ No newline at end of file From fe555e040cf87bfd25f966481f4a8565464d3a96 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 5 Mar 2025 12:41:55 +1300 Subject: [PATCH 3/3] Fix checks --- composer.lock | 135 +++++++++++++++++++---------------------- src/Database/PDO.php | 1 + tests/unit/PDOTest.php | 12 ++-- 3 files changed, 68 insertions(+), 80 deletions(-) diff --git a/composer.lock b/composer.lock index c2ab4ae14..28098179d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "brick/math", - "version": "0.12.1", + "version": "0.12.3", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1" + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/f510c0a40911935b77b86859eb5223d58d660df1", - "reference": "f510c0a40911935b77b86859eb5223d58d660df1", + "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", "shasum": "" }, "require": { @@ -26,7 +26,7 @@ "require-dev": { "php-coveralls/php-coveralls": "^2.2", "phpunit/phpunit": "^10.1", - "vimeo/psalm": "5.16.0" + "vimeo/psalm": "6.8.8" }, "type": "library", "autoload": { @@ -56,7 +56,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.12.1" + "source": "https://github.com/brick/math/tree/0.12.3" }, "funding": [ { @@ -64,7 +64,7 @@ "type": "github" } ], - "time": "2023-11-29T23:19:16+00:00" + "time": "2025-02-28T13:11:00+00:00" }, { "name": "composer/semver", @@ -149,16 +149,16 @@ }, { "name": "google/protobuf", - "version": "v4.29.3", + "version": "v4.30.0", "source": { "type": "git", "url": "https://github.com/protocolbuffers/protobuf-php.git", - "reference": "ab5077c2cfdd1f415f42d11fdbdf903ba8e3d9b7" + "reference": "e1d66682f6836aa87820400f0aa07d9eb566feb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/ab5077c2cfdd1f415f42d11fdbdf903ba8e3d9b7", - "reference": "ab5077c2cfdd1f415f42d11fdbdf903ba8e3d9b7", + "url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/e1d66682f6836aa87820400f0aa07d9eb566feb6", + "reference": "e1d66682f6836aa87820400f0aa07d9eb566feb6", "shasum": "" }, "require": { @@ -187,9 +187,9 @@ "proto" ], "support": { - "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.29.3" + "source": "https://github.com/protocolbuffers/protobuf-php/tree/v4.30.0" }, - "time": "2025-01-08T21:00:13+00:00" + "time": "2025-03-04T22:54:49+00:00" }, { "name": "jean85/pretty-package-versions", @@ -1210,16 +1210,16 @@ }, { "name": "ramsey/collection", - "version": "2.0.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/ramsey/collection.git", - "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5" + "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", - "reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5", + "url": "https://api.github.com/repos/ramsey/collection/zipball/3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", + "reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109", "shasum": "" }, "require": { @@ -1227,25 +1227,22 @@ }, "require-dev": { "captainhook/plugin-composer": "^5.3", - "ergebnis/composer-normalize": "^2.28.3", - "fakerphp/faker": "^1.21", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", "hamcrest/hamcrest-php": "^2.0", - "jangregor/phpstan-prophecy": "^1.0", - "mockery/mockery": "^1.5", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", "php-parallel-lint/php-console-highlighter": "^1.0", - "php-parallel-lint/php-parallel-lint": "^1.3", - "phpcsstandards/phpcsutils": "^1.0.0-rc1", - "phpspec/prophecy-phpunit": "^2.0", - "phpstan/extension-installer": "^1.2", - "phpstan/phpstan": "^1.9", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-phpunit": "^1.3", - "phpunit/phpunit": "^9.5", - "psalm/plugin-mockery": "^1.1", - "psalm/plugin-phpunit": "^0.18.4", - "ramsey/coding-standard": "^2.0.3", - "ramsey/conventional-commits": "^1.3", - "vimeo/psalm": "^5.4" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" }, "type": "library", "extra": { @@ -1283,19 +1280,9 @@ ], "support": { "issues": "https://github.com/ramsey/collection/issues", - "source": "https://github.com/ramsey/collection/tree/2.0.0" + "source": "https://github.com/ramsey/collection/tree/2.1.0" }, - "funding": [ - { - "url": "https://github.com/ramsey", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/ramsey/collection", - "type": "tidelift" - } - ], - "time": "2022-12-31T21:50:55+00:00" + "time": "2025-03-02T04:48:29+00:00" }, { "name": "ramsey/uuid", @@ -1458,16 +1445,16 @@ }, { "name": "symfony/http-client", - "version": "v7.2.3", + "version": "v7.2.4", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "7ce6078c79a4a7afff931c413d2959d3bffbfb8d" + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/7ce6078c79a4a7afff931c413d2959d3bffbfb8d", - "reference": "7ce6078c79a4a7afff931c413d2959d3bffbfb8d", + "url": "https://api.github.com/repos/symfony/http-client/zipball/78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", + "reference": "78981a2ffef6437ed92d4d7e2a86a82f256c6dc6", "shasum": "" }, "require": { @@ -1533,7 +1520,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v7.2.3" + "source": "https://github.com/symfony/http-client/tree/v7.2.4" }, "funding": [ { @@ -1549,7 +1536,7 @@ "type": "tidelift" } ], - "time": "2025-01-28T15:51:35+00:00" + "time": "2025-02-13T10:27:23+00:00" }, { "name": "symfony/http-client-contracts", @@ -2098,16 +2085,16 @@ }, { "name": "utopia-php/framework", - "version": "0.33.16", + "version": "0.33.17", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "e91d4c560d1b809e25faa63d564fef034363b50f" + "reference": "73fac6fbce9f56282dba4e52a58cf836ec434644" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/e91d4c560d1b809e25faa63d564fef034363b50f", - "reference": "e91d4c560d1b809e25faa63d564fef034363b50f", + "url": "https://api.github.com/repos/utopia-php/http/zipball/73fac6fbce9f56282dba4e52a58cf836ec434644", + "reference": "73fac6fbce9f56282dba4e52a58cf836ec434644", "shasum": "" }, "require": { @@ -2139,9 +2126,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.33.16" + "source": "https://github.com/utopia-php/http/tree/0.33.17" }, - "time": "2025-01-16T15:58:50+00:00" + "time": "2025-02-24T17:35:48+00:00" }, { "name": "utopia-php/mongo", @@ -2390,16 +2377,16 @@ }, { "name": "laravel/pint", - "version": "v1.20.0", + "version": "v1.21.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" + "reference": "531fa0871fbde719c51b12afa3a443b8f4e4b425" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "url": "https://api.github.com/repos/laravel/pint/zipball/531fa0871fbde719c51b12afa3a443b8f4e4b425", + "reference": "531fa0871fbde719c51b12afa3a443b8f4e4b425", "shasum": "" }, "require": { @@ -2407,15 +2394,15 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.1.0" + "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.66.0", - "illuminate/view": "^10.48.25", - "larastan/larastan": "^2.9.12", - "laravel-zero/framework": "^10.48.25", + "friendsofphp/php-cs-fixer": "^3.68.5", + "illuminate/view": "^11.42.0", + "larastan/larastan": "^3.0.4", + "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^1.17.0", + "nunomaduro/termwind": "^2.3", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -2452,7 +2439,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-01-14T16:20:53+00:00" + "time": "2025-02-18T03:18:57+00:00" }, { "name": "myclabs/deep-copy", @@ -2724,16 +2711,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.18", + "version": "1.12.19", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "fef9f07814a573399229304bb0046affdf558812" + "reference": "c42ba9bab7a940ed00092ecb1c77bad98896d789" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/fef9f07814a573399229304bb0046affdf558812", - "reference": "fef9f07814a573399229304bb0046affdf558812", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c42ba9bab7a940ed00092ecb1c77bad98896d789", + "reference": "c42ba9bab7a940ed00092ecb1c77bad98896d789", "shasum": "" }, "require": { @@ -2778,7 +2765,7 @@ "type": "github" } ], - "time": "2025-02-13T12:44:44+00:00" + "time": "2025-02-19T15:42:21+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/src/Database/PDO.php b/src/Database/PDO.php index fb3e34395..ae1dc93f8 100644 --- a/src/Database/PDO.php +++ b/src/Database/PDO.php @@ -44,6 +44,7 @@ public function __call(string $method, array $args): mixed try { return $this->pdo->{$method}(...$args); } catch (\Throwable $e) { + /** @phpstan-ignore-next-line can't find static method */ if (DetectsLostConnections::causedByLostConnection($e)) { $this->reconnect(); return $this->pdo->{$method}(...$args); diff --git a/tests/unit/PDOTest.php b/tests/unit/PDOTest.php index 5d343e39c..5545c9ed7 100644 --- a/tests/unit/PDOTest.php +++ b/tests/unit/PDOTest.php @@ -8,7 +8,7 @@ class PDOTest extends TestCase { - public function testMethodCallIsForwardedToPDO() + public function testMethodCallIsForwardedToPDO(): void { $dsn = 'sqlite::memory:'; $pdoWrapper = new PDO($dsn, null, null); @@ -41,7 +41,7 @@ public function testMethodCallIsForwardedToPDO() $this->assertSame($pdoStatementMock, $result); } - public function testLostConnectionRetriesCall() + public function testLostConnectionRetriesCall(): void { $dsn = 'sqlite::memory:'; $pdoWrapper = $this->getMockBuilder(PDO::class) @@ -80,7 +80,7 @@ public function testLostConnectionRetriesCall() $this->assertSame($pdoStatementMock, $result); } - public function testNonLostConnectionExceptionIsRethrown() + public function testNonLostConnectionExceptionIsRethrown(): void { $dsn = 'sqlite::memory:'; $pdoWrapper = new PDO($dsn, null, null); @@ -106,7 +106,7 @@ public function testNonLostConnectionExceptionIsRethrown() $pdoWrapper->query('SELECT 1'); } - public function testReconnectCreatesNewPDOInstance() + public function testReconnectCreatesNewPDOInstance(): void { $dsn = 'sqlite::memory:'; $pdoWrapper = new PDO($dsn, null, null); @@ -122,7 +122,7 @@ public function testReconnectCreatesNewPDOInstance() $this->assertNotSame($oldPDO, $newPDO, "Reconnect should create a new PDO instance"); } - public function testMethodCallForPrepare() + public function testMethodCallForPrepare(): void { $dsn = 'sqlite::memory:'; $pdoWrapper = new PDO($dsn, null, null); @@ -149,4 +149,4 @@ public function testMethodCallForPrepare() $this->assertSame($pdoStatementMock, $result); } -} \ No newline at end of file +}