From 656258b0de20ae0b049b32d077ef09e8a7f18686 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Thu, 23 Jul 2026 21:20:56 +1200 Subject: [PATCH] (fix): translate getDocument PDO exceptions --- src/Database/Adapter/SQL.php | 33 +++++-- tests/unit/SQLGetDocumentTest.php | 150 ++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 tests/unit/SQLGetDocumentTest.php diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 03aefd5bc7..51f70fc477 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -397,17 +397,36 @@ public function getDocument(Document $collection, string $id, array $queries = [ $sql = $this->trigger(Database::EVENT_DOCUMENT_READ, $sql); - $stmt = $this->getPDO()->prepare($sql); + $stmt = null; + $document = []; + $exception = null; - $stmt->bindValue(':_uid', $id); + try { + $stmt = $this->getPDO()->prepare($sql); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->getTenant()); + $stmt->bindValue(':_uid', $id); + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->getTenant()); + } + + $this->execute($stmt); + $document = $stmt->fetchAll(); + } catch (PDOException $e) { + $exception = $e; + } finally { + if ($stmt !== null) { + try { + $stmt->closeCursor(); + } catch (PDOException $e) { + $exception ??= $e; + } + } } - $stmt->execute(); - $document = $stmt->fetchAll(); - $stmt->closeCursor(); + if ($exception !== null) { + throw $this->processException($exception); + } if (empty($document)) { return new Document([]); diff --git a/tests/unit/SQLGetDocumentTest.php b/tests/unit/SQLGetDocumentTest.php new file mode 100644 index 0000000000..d938b85d41 --- /dev/null +++ b/tests/unit/SQLGetDocumentTest.php @@ -0,0 +1,150 @@ +createTimeoutException(); + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->once()) + ->method('bindValue') + ->with(':_uid', 'document') + ->willReturn(true); + $statement->expects($this->once()) + ->method('execute') + ->willThrowException($exception); + $statement->expects($this->never()) + ->method('fetchAll'); + $statement->expects($this->once()) + ->method('closeCursor') + ->willThrowException(new PDOException('Failed to close cursor')); + + $this->assertTimeout($this->createMySQL($statement), $exception); + } + + public function testTranslatesFetchTimeoutAndClosesCursor(): void + { + $exception = $this->createTimeoutException(); + + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->once()) + ->method('bindValue') + ->with(':_uid', 'document') + ->willReturn(true); + $statement->expects($this->once()) + ->method('execute') + ->willReturn(true); + $statement->expects($this->once()) + ->method('fetchAll') + ->willThrowException($exception); + $statement->expects($this->once()) + ->method('closeCursor') + ->willReturn(true); + + $this->assertTimeout($this->createMySQL($statement), $exception); + } + + public function testUsesPostgresExecuteHook(): void + { + $statement = $this->getMockBuilder(\PDOStatement::class) + ->disableOriginalConstructor() + ->getMock(); + $statement->expects($this->once()) + ->method('bindValue') + ->with(':_uid', 'document') + ->willReturn(true); + $statement->expects($this->once()) + ->method('execute') + ->willReturn(true); + $statement->expects($this->once()) + ->method('fetchAll') + ->willReturn([]); + $statement->expects($this->once()) + ->method('closeCursor') + ->willReturn(true); + + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturn($statement); + $pdo->expects($this->exactly(2)) + ->method('exec') + ->withConsecutive( + ["SET statement_timeout = '25ms'"], + ['RESET statement_timeout'] + ) + ->willReturnOnConsecutiveCalls(0, 0); + + $adapter = new Postgres($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + $adapter->setTimeout(25); + + $document = $adapter->getDocument( + new Document(['$id' => 'collection']), + 'document' + ); + + $this->assertSame([], $document->getArrayCopy()); + } + + private function assertTimeout(MySQL $adapter, PDOException $exception): void + { + try { + $adapter->getDocument( + new Document(['$id' => 'collection']), + 'document' + ); + } catch (TimeoutException $timeout) { + $this->assertSame($exception, $timeout->getPrevious()); + + return; + } + + $this->fail('Expected a timeout exception.'); + } + + private function createMySQL(\PDOStatement $statement): MySQL + { + $pdo = $this->getMockBuilder(\PDO::class) + ->disableOriginalConstructor() + ->getMock(); + $pdo->expects($this->once()) + ->method('prepare') + ->willReturn($statement); + + $adapter = new MySQL($pdo); + $adapter->setDatabase('database'); + $adapter->setNamespace('namespace'); + + return $adapter; + } + + private function createTimeoutException(): PDOException + { + $exception = new PDOException('Query execution was interrupted'); + $code = new ReflectionProperty(Exception::class, 'code'); + $code->setValue($exception, 'HY000'); + $exception->errorInfo = ['HY000', 3024, 'Query execution was interrupted']; + + return $exception; + } +}