Skip to content
14 changes: 7 additions & 7 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
Expand All @@ -25,18 +25,18 @@
<env name="TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME" value="127.0.0.1"/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_PORT" value="3306"/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_DATABASE" value="webware"/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_USERNAME" value="root"/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD" value=""/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_USERNAME" value="webware"/>
<env name="TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD" value="webware"/>

<env name="TESTS_PHPDB_ADAPTER_PGSQL" value="true"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_HOSTNAME" value="pgsql"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_HOSTNAME" value="127.0.0.1"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_PORT" value="5432"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_DATABASE" value="webware"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_USERNAME" value="postgres"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_PASSWORD" value="postgres"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_USERNAME" value="webware"/>
<env name="TESTS_PHPDB_ADAPTER_PGSQL_PASSWORD" value="webware"/>

<env name="TESTS_PHPDB_ADAPTER_SQLITE" value="true"/>
<env name="TESTS_PHPDB_ADAPTER_SQLITE_DSN" value="sqlite:/tmp/webware-phpdb.sqlite"/>
<env name="TESTS_PHPDB_ADAPTER_SQLITE_DSN" value="sqlite::TMP:/webware-phpdb.sqlite"/>
</php>
<source restrictNotices="true" ignoreIndirectDeprecations="true">
<include>
Expand Down
51 changes: 49 additions & 2 deletions src/Session/DbSessionHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,30 @@
use PhpDb\Session\Sql\Insert;
use PhpDb\Sql\Exception\InvalidArgumentException as SqlInvalidArgumentException;
use PhpDb\Sql\Sql;
use Random\RandomException;
use SessionHandlerInterface;
use SessionIdInterface;
use SessionUpdateTimestampHandlerInterface;

use function bin2hex;
use function date;
use function ini_get;
use function is_array;
use function is_string;
use function random_bytes;
use function time;

final class DbSessionHandler implements SessionHandlerInterface
/**
* Session handler backed by the session table.
*
* Both auxiliary session interfaces are implemented because PHP 8.6 warns
* when a SessionHandlerInterface implementation omits create_sid() and
* validateId(), which SessionHandlerInterface itself declares from PHP 9.0.
*/
final class DbSessionHandler implements
SessionHandlerInterface,
SessionIdInterface,
SessionUpdateTimestampHandlerInterface
{
private const string TIMESTAMP_FORMAT = 'Y-m-d H:i:s';

Expand All @@ -47,6 +62,16 @@
return true;
}

/**
* @throws RandomException
*/
// @mago-expect lint:method-name -- create_sid is mandated by SessionIdInterface.
#[Override]
public function create_sid(): string
{
return bin2hex(random_bytes(16));
}

/**
* @throws SqlInvalidArgumentException
*/
Expand All @@ -64,7 +89,8 @@
* @throws SqlInvalidArgumentException
*/
#[Override]
public function gc(int $maxLifetime): int|false
// @mago-expect lint:variable-name -- max_lifetime is mandated by SessionHandlerInterface::gc().
public function gc(int $max_lifetime): int|false
{
$cutoff = date(format: self::TIMESTAMP_FORMAT);

Expand Down Expand Up @@ -94,12 +120,33 @@
->where(['id' => $id]);
$select->where->greaterThan('expires_at', date(format: self::TIMESTAMP_FORMAT));

$row = $this->sql->prepareStatementForSqlObject($select)->execute()?->current();

Check warning on line 123 in src/Session/DbSessionHandler.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.5)

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 123 in src/Session/DbSessionHandler.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.4)

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.
$payload = is_array($row) ? $row['payload'] ?? false : false;

Check warning on line 124 in src/Session/DbSessionHandler.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.5)

mixed-assignment

Assigning `nonnull` type to a variable may lead to unexpected behavior. >Assigning `nonnull` type here. Using `nonnull` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 124 in src/Session/DbSessionHandler.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.4)

mixed-assignment

Assigning `nonnull` type to a variable may lead to unexpected behavior. >Assigning `nonnull` type here. Using `nonnull` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

return is_string($payload) ? $payload : false;
}

/**
* @throws SqlInvalidArgumentException
*/
#[Override]
public function updateTimestamp(string $id, string $data): bool
{
return $this->write(
id : $id,
data: $data,
);
}

/**
* @throws SqlInvalidArgumentException
*/
#[Override]
public function validateId(string $id): bool
{
return false !== $this->read($id);
}

/**
* @throws SqlInvalidArgumentException
*/
Expand Down
99 changes: 89 additions & 10 deletions test/integration/FixtureLoader/PgsqlFixtureLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,69 @@

use PhpDb\Adapter\AdapterInterface;
use PhpDb\Pgsql;
use Throwable;
use WebwareTestIntegration\PhpDb\Support\AdapterFactory;

use function getenv;
use function sprintf;
use function str_contains;
use function str_replace;

/**
* Ensures the pgsql test database exists, following the same shape as the
* mysql loader.
*
* CI provisions the database through the container environment (POSTGRES_DB),
* so it is already present when the suite starts, and this is a no-op. Locally
* it creates the database when missing. Either way the create path never drops
* anything.
*
* Postgres has no CREATE DATABASE IF NOT EXISTS, so existence is checked
* against pg_database first.
*
* Nothing here drops a database with sessions attached. Postgres refuses that
* where mysql allows it, so a drop-then-create cycle would need FORCE and
* therefore pg_signal_backend, a privilege a test role should not hold.
*/
final class PgsqlFixtureLoader implements FixtureLoaderInterface
{
private const string MAINTENANCE_DATABASE = 'postgres';

public function createDatabase(): void
{
$database = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE');
$adapter = $this->adapter();
if ($this->exists()) {
return;
}

$adapter->executeQuery(sql: sprintf('DROP DATABASE IF EXISTS "%s" WITH (FORCE)', $database));
$adapter->executeQuery(sql: sprintf('CREATE DATABASE "%s"', $database));
$this->adapter()->executeQuery(
sql: sprintf(
'CREATE DATABASE "%s" OWNER "%s"',
$this->database(),
$this->username(),
),
);
}

public function dropDatabase(): void
{
$database = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE');
$adapter = $this->adapter();

$adapter->executeQuery(sql: sprintf('DROP DATABASE IF EXISTS "%s" WITH (FORCE)', $database));
try {
$this->adapter()->executeQuery(
sql: sprintf('DROP DATABASE IF EXISTS "%s"', $this->database()),
);
} catch (Throwable $e) {
// Postgres refuses to drop a database that has a session attached,
// and at teardown the suite's own connection is still open. A
// non-superuser role cannot terminate its own backend, so this is
// unreachable without pg_signal_backend.
//
// Tolerating exactly this case is safe because the outcome the drop
// exists to achieve is already guaranteed: CI uses a disposable
// container per leg, and each test creates and drops its own table,
// so no state carries between runs.
if (! str_contains($e->getMessage(), '55006')) {
throw $e;
}
}
}

private function adapter(): AdapterInterface
Expand All @@ -38,12 +79,50 @@ private function adapter(): AdapterInterface
'connection' => [
'hostname' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_HOSTNAME'),
'port' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PORT'),
'dbname' => 'postgres',
'username' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_USERNAME'),
'dbname' => self::MAINTENANCE_DATABASE,
'username' => $this->username(),
'password' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_PASSWORD'),
],
],
driverDependencies: new Pgsql\ConfigProvider()->getDependencies(),
);
}

private function database(): string
{
return (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_DATABASE');
}

private function exists(): bool
{
$rows = $this
->adapter()
->executeQuery(
sql: sprintf(
'SELECT 1 FROM pg_database WHERE datname = %s',
$this->quotedDatabase(),
),
)
->getQueryResult()
->toArray();

return [] !== $rows;
}

private function quotedDatabase(): string
{
return sprintf(
"'%s'",
str_replace(
search : "'",
replace: "''",
subject: $this->database(),
),
);
}

private function username(): string
{
return (string) getenv(name: 'TESTS_PHPDB_ADAPTER_PGSQL_USERNAME');
}
}
17 changes: 4 additions & 13 deletions test/integration/FixtureLoader/SqliteFixtureLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@

namespace WebwareTestIntegration\PhpDb\FixtureLoader;

use function getenv;
use WebwareTestIntegration\PhpDb\SqliteDsn;

use function is_file;
use function str_replace;
use function unlink;

final class SqliteFixtureLoader implements FixtureLoaderInterface
{
public function createDatabase(): void
{
$path = $this->databasePath();
$path = SqliteDsn::path();

if (is_file(filename: $path)) {
unlink(filename: $path);
Expand All @@ -22,19 +22,10 @@ public function createDatabase(): void

public function dropDatabase(): void
{
$path = $this->databasePath();
$path = SqliteDsn::path();

if (is_file(filename: $path)) {
unlink(filename: $path);
}
}

private function databasePath(): string
{
return str_replace(
search : 'sqlite:',
replace: '',
subject: (string) getenv(name: 'TESTS_PHPDB_ADAPTER_SQLITE_DSN'),
);
}
}
2 changes: 1 addition & 1 deletion test/integration/SchemaFactoryIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public function itCreatesPrefixedTableOnSqlite(): void
AdapterInterface::class => [
'driver' => Sqlite\Pdo\Driver::class,
'connection' => [
'dsn' => (string) getenv(name: 'TESTS_PHPDB_ADAPTER_SQLITE_DSN'),
'dsn' => SqliteDsn::resolve(),
],
],
SchemaInterface::class => [
Expand Down
32 changes: 32 additions & 0 deletions test/integration/Session/DbSessionHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
#[CoversMethod(DbSessionHandler::class, 'write')]
#[CoversMethod(DbSessionHandler::class, 'destroy')]
#[CoversMethod(DbSessionHandler::class, 'gc')]
#[CoversMethod(DbSessionHandler::class, 'updateTimestamp')]
#[CoversMethod(DbSessionHandler::class, 'validateId')]
#[RequiresPhpExtension('pdo_mysql')]
#[Group('integration')]
#[Group('integration-mysql')]
Expand Down Expand Up @@ -116,6 +118,36 @@ public function readReturnsWhatWasWritten(): void
self::assertSame('user|s:4:"joey";', $handler->read('abc'));
}

#[Test]
public function updateTimestampPersistsTheSessionAndExtendsTheExpiry(): void
{
$past = date(
format : 'Y-m-d H:i:s',
timestamp: time() - 60,
);
$this->insertRow(
id : 'abc',
payload : 'data',
expiresAt: $past,
);

$handler = new DbSessionHandler($this->adapter);

self::assertTrue($handler->updateTimestamp('abc', 'data'));
self::assertSame('data', $this->payloadOf('abc'));
self::assertGreaterThan($past, (string) $this->expiresAtOf('abc'));
}

#[Test]
public function validateIdReportsWhetherTheSessionIsReadable(): void
{
$handler = new DbSessionHandler($this->adapter);
$handler->write('abc', 'data');

self::assertTrue($handler->validateId('abc'));
self::assertFalse($handler->validateId('missing'));
}

#[Test]
public function writeExpiresTheSessionAfterTheConfiguredLifetime(): void
{
Expand Down
37 changes: 37 additions & 0 deletions test/integration/SqliteDsn.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace WebwareTestIntegration\PhpDb;

use function getenv;
use function str_replace;
use function sys_get_temp_dir;

/**
* Resolves the sqlite DSN from the environment.
*
* The DSN may contain the `:TMP:` token, which is replaced with the platform
* temp directory. That keeps one definition working on Linux (where the
* fixture lives under `/tmp`) and on Windows (where it does not exist).
*/
final class SqliteDsn
{
public static function path(): string
{
return str_replace(
search : 'sqlite:',
replace: '',
subject: self::resolve(),
);
}

public static function resolve(): string
{
return str_replace(
search : ':TMP:',
replace: sys_get_temp_dir(),
subject: (string) getenv(name: 'TESTS_PHPDB_ADAPTER_SQLITE_DSN'),
);
}
}
Loading
Loading