Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
</properties>
</rule>
<!-- https://github.com/slevomat/coding-standard/blob/master/doc/classes.md -->
<rule ref="SlevomatCodingStandard.Classes.RequireAbstractOrFinal"/>
<rule ref="SlevomatCodingStandard.Classes.RequireAbstractOrFinal">
<!-- @api classes live directly in src/ and may be open for extension by consumers -->
<exclude-pattern>src/*.php</exclude-pattern>
</rule>
<rule ref="SlevomatCodingStandard.Classes.ClassConstantVisibility"/>
<rule ref="SlevomatCodingStandard.Classes.ModernClassNameReference"/>
<rule ref="SlevomatCodingStandard.Classes.UselessLateStaticBinding"/>
Expand Down
6 changes: 3 additions & 3 deletions src/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
/**
* @api
*/
final class Driver implements DriverInterface
class Driver implements DriverInterface
{
/**
* @var Closure():PDO
Expand Down Expand Up @@ -102,8 +102,8 @@ private function makeConnection(): ConnectionInterface
$timeout = 300;

/**
* @note если вдруг экземпляр класса Migrator будет использоваться как сервис, то переиспользуем коннект.
* Но с ограничением по времени, долго держать в памяти не будем.
* @note if a Migrator instance is used as a long-lived service, reuse the connection.
* Capped by a TTL so we do not hold it in memory indefinitely.
*/
if (!$this->connectionInstance instanceof Connection || $this->connectionTimer < time()) {
$this->connectionTimer = time() + $timeout;
Expand Down
6 changes: 3 additions & 3 deletions src/Type.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,18 @@ enum Type

case PDO_MYSQL;

case PDO_MSSQL;

case PDO_SQLITE;

/**
* @return non-empty-lowercase-string
*/
public function value(): string
{
[, $db] = explode('_', $this->name);

/**
* @var non-empty-lowercase-string
*/
return strtolower($db);
return strtolower(substr($this->name, 4));
}
}
11 changes: 2 additions & 9 deletions src/internal/ErrorInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace dbschemix\pdo\internal;

use Override;
use Stringable;

/**
Expand All @@ -25,15 +26,7 @@ public function __construct(array $errorInfo)
$this->message = sprintf('SQLSTATE[%s]: error: %s', $errorInfo[0], $errorInfo[2]);
}

/**
* @return non-empty-string
*/
public function getMessage(): string
{
return $this->message;
}

#[\Override]
#[Override]
public function __toString(): string
{
return $this->message;
Expand Down
5 changes: 3 additions & 2 deletions src/internal/Statement.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace dbschemix\pdo\internal;

use Override;
use PDO;
use dbschemix\core\connection\StatementInterface;

Expand All @@ -20,7 +21,7 @@ protected function __construct(
) {
}

#[\Override]
#[Override]
public function fetchRecord(string $query, array $params = []): array
{
$statement = $this->connection->prepare($query);
Expand All @@ -38,7 +39,7 @@ public function fetchRecord(string $query, array $params = []): array
return [];
}

#[\Override]
#[Override]
public function exec(string $query, array $params = []): void
{
if ($params === []) {
Expand Down
178 changes: 178 additions & 0 deletions tests/workflow/ConnectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

declare(strict_types=1);

namespace dbschemix\pdo\tests\workflow;

// Note: the branch `return []` in fetchRecord (when execute() returns false) and the
// `$this->prepareException()` branch are not reachable through a real in-memory SQLite
// PDO handle under normal conditions. These two edges are therefore intentionally left
// without direct coverage here, consistent with the strategy used across this test suite.

use Throwable;
use PDO;
use Testo\Assert;
use Testo\Lifecycle\BeforeTest;
use Testo\Test;
use dbschemix\pdo\internal\Connection;
use dbschemix\pdo\internal\Transaction;

#[Test]
final class ConnectionTest
{
private PDO $pdo;

private Connection $connection;

#[BeforeTest]
public function init(): void
{
$this->pdo = new PDO(dsn: 'sqlite::memory:');
$this->pdo->exec(
'CREATE TABLE migration (name TEXT PRIMARY KEY, version INTEGER DEFAULT 0, atime TEXT)'
);

$this->connection = new Connection($this->pdo, Transaction::class);
}

/**
* exec() without params uses PDO::exec() directly (the empty-params branch).
* Verified by reading the inserted row back through fetchRecord().
*
* @throws Throwable
*/
public function execWithoutParams(): void
{
$this->connection->exec(
"INSERT INTO migration (name, version, atime) VALUES ('v1', 1, '2026-01-01 00:00:00')"
);

$data = $this->connection->fetchRecord('SELECT name, version FROM migration');
Assert::count($data, 1);
Assert::array($data)->hasKeys('v1');
}

/**
* exec() with named params uses prepare()+execute() (the params branch).
* The param values are bound as literals, not interpolated into SQL.
*
* @throws Throwable
*/
public function execWithNamedParams(): void
{
$this->connection->exec(
'INSERT INTO migration (name, version, atime) VALUES (:name, :version, :atime)',
['name' => '202501010000_create_users', 'version' => 42, 'atime' => '2026-01-01 00:00:00'],
);

$data = $this->connection->fetchRecord('SELECT name, version FROM migration');
Assert::count($data, 1);
Assert::array($data)->hasKeys('202501010000_create_users');
Assert::equals($data['202501010000_create_users'], 42);
}

/**
* exec() with params treats SQL-special characters in parameter values as literals,
* not as SQL fragments — the table must survive and contain exactly one row.
*
* @throws Throwable
*/
public function execParamsAreNotInterpolated(): void
{
$maliciousName = "'); DROP TABLE migration; --";

$this->connection->exec(
'INSERT INTO migration (name, version, atime) VALUES (:name, :version, :atime)',
['name' => $maliciousName, 'version' => 1, 'atime' => '2026-01-01 00:00:00'],
);

$data = $this->connection->fetchRecord('SELECT name, version FROM migration');
Assert::count($data, 1);
Assert::array($data)->hasKeys($maliciousName);
}

/**
* fetchRecord() without params returns all rows as name→version map (FETCH_KEY_PAIR).
*
* @throws Throwable
*/
public function fetchRecordWithoutParams(): void
{
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v1', 10, '2026-01-01 00:00:00')");
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v2', 20, '2026-01-02 00:00:00')");

$data = $this->connection->fetchRecord('SELECT name, version FROM migration');

Assert::count($data, 2);
Assert::array($data)->hasKeys('v1', 'v2');
Assert::equals($data['v1'], 10);
Assert::equals($data['v2'], 20);
}

/**
* fetchRecord() with params filters rows by the bound value.
*
* @throws Throwable
*/
public function fetchRecordWithParams(): void
{
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v1', 10, '2026-01-01 00:00:00')");
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v2', 20, '2026-01-02 00:00:00')");
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v3', 10, '2026-01-03 00:00:00')");

$data = $this->connection->fetchRecord(
'SELECT name, version FROM migration WHERE version = :version',
['version' => 10],
);

Assert::count($data, 2);
Assert::array($data)->hasKeys('v1', 'v3');
Assert::array($data)->doesNotHaveKeys('v2');
}

/**
* fetchRecord() returns an empty array when the query matches no rows.
*
* @throws Throwable
*/
public function fetchRecordReturnsEmptyArrayOnNoMatch(): void
{
$this->pdo->exec("INSERT INTO migration (name, version, atime) VALUES ('v1', 1, '2026-01-01 00:00:00')");

$data = $this->connection->fetchRecord(
'SELECT name, version FROM migration WHERE version = :version',
['version' => 999],
);

Assert::blank($data);
}

/**
* Three consecutive exec()-with-params calls each use a fresh prepare()+execute() cycle.
* fetchRecord() must return all three rows, proving no statement handle is reused
* incorrectly between calls.
*
* @throws Throwable
*/
public function execMultipleRowsWithParams(): void
{
$rows = [
['name' => '202501010000_first', 'version' => 1, 'atime' => '2026-01-01 00:00:00'],
['name' => '202501010001_second', 'version' => 2, 'atime' => '2026-01-02 00:00:00'],
['name' => '202501010002_third', 'version' => 3, 'atime' => '2026-01-03 00:00:00'],
];

foreach ($rows as $row) {
$this->connection->exec(
'INSERT INTO migration (name, version, atime) VALUES (:name, :version, :atime)',
$row,
);
}

$data = $this->connection->fetchRecord('SELECT name, version FROM migration');
Assert::count($data, 3);
Assert::equals($data['202501010000_first'], 1);
Assert::equals($data['202501010001_second'], 2);
Assert::equals($data['202501010002_third'], 3);
}
}
Loading