From cf9730b06385f6158dc4b063f807fa8cc5396146 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 30 Sep 2026 18:47:58 -0500 Subject: [PATCH] feat: add the database-backed session handler and persistence Moves the PhpDb session handler, the mezzio session persistence and their upsert Insert out of the IMS copy of php-db/phpdb-mezzio-session into this bridge, under the PhpDb\Session namespace it presents. Nothing is deleted from IMS. The code is brought up to the stack standards rather than copied verbatim: the table name comes from one SessionTable enum, settings resolve through IniDefaults, payload encoding is SessionPayload, exceptions are declared, and every finding is fixed at source with no baseline entry. session:init-db creates the table. Its columns come from the IMS session schema (id, payload, modified_at, expires_at; primary key id; index on expires_at), less the database-side timestamp defaults because every write supplies both timestamps. SessionProvider is a second config provider and aliases SessionPersistenceInterface to the database persistence. mezzio-session and webware-console are dev dependencies and suggestions, not requirements. Signed-off-by: Joey Smith --- composer.json | 12 +- composer.lock | 1257 ++++++++++++++--- .../Container/InitDbCommandFactory.php | 39 + src/Session/Console/Ddl/Column/MediumText.php | 27 + src/Session/Console/InitDbCommand.php | 97 ++ src/Session/Console/Schema/SessionSchema.php | 78 + .../Container/DbSessionHandlerFactory.php | 35 + .../PhpDbSessionPersistenceFactory.php | 42 + src/Session/DbSessionHandler.php | 127 ++ src/Session/IniDefaults.php | 120 ++ src/Session/PhpDbSessionPersistence.php | 227 +++ src/Session/SessionPayload.php | 50 + src/Session/SessionProvider.php | 66 + src/Session/SessionTable.php | 25 + src/Session/Sql/Insert.php | 42 + .../Session/Console/InitDbCommandTest.php | 119 ++ .../Session/DbSessionHandlerTest.php | 146 ++ .../Session/PhpDbSessionPersistenceTest.php | 289 ++++ .../Support/MysqlSessionTestCase.php | 115 ++ .../integration/WebwareProviderWiringTest.php | 5 +- .../Container/InitDbCommandFactoryTest.php | 43 + .../Console/Ddl/Column/MediumTextTest.php | 41 + .../Session/Console/InitDbCommandTest.php | 84 ++ .../Console/Schema/SessionSchemaTest.php | 135 ++ .../Container/SessionFactoriesTest.php | 74 + test/unit/Session/IniDefaultsTest.php | 111 ++ .../Session/PhpDbSessionPersistenceTest.php | 59 + test/unit/Session/SessionPayloadTest.php | 43 + test/unit/Session/SessionProviderTest.php | 66 + test/unit/Session/Sql/InsertTest.php | 46 + test/unit/Support/DdlAdapterTrait.php | 42 + 31 files changed, 3483 insertions(+), 179 deletions(-) create mode 100644 src/Session/Console/Container/InitDbCommandFactory.php create mode 100644 src/Session/Console/Ddl/Column/MediumText.php create mode 100644 src/Session/Console/InitDbCommand.php create mode 100644 src/Session/Console/Schema/SessionSchema.php create mode 100644 src/Session/Container/DbSessionHandlerFactory.php create mode 100644 src/Session/Container/PhpDbSessionPersistenceFactory.php create mode 100644 src/Session/DbSessionHandler.php create mode 100644 src/Session/IniDefaults.php create mode 100644 src/Session/PhpDbSessionPersistence.php create mode 100644 src/Session/SessionPayload.php create mode 100644 src/Session/SessionProvider.php create mode 100644 src/Session/SessionTable.php create mode 100644 src/Session/Sql/Insert.php create mode 100644 test/integration/Session/Console/InitDbCommandTest.php create mode 100644 test/integration/Session/DbSessionHandlerTest.php create mode 100644 test/integration/Session/PhpDbSessionPersistenceTest.php create mode 100644 test/integration/Support/MysqlSessionTestCase.php create mode 100644 test/unit/Session/Console/Container/InitDbCommandFactoryTest.php create mode 100644 test/unit/Session/Console/Ddl/Column/MediumTextTest.php create mode 100644 test/unit/Session/Console/InitDbCommandTest.php create mode 100644 test/unit/Session/Console/Schema/SessionSchemaTest.php create mode 100644 test/unit/Session/Container/SessionFactoriesTest.php create mode 100644 test/unit/Session/IniDefaultsTest.php create mode 100644 test/unit/Session/PhpDbSessionPersistenceTest.php create mode 100644 test/unit/Session/SessionPayloadTest.php create mode 100644 test/unit/Session/SessionProviderTest.php create mode 100644 test/unit/Session/Sql/InsertTest.php create mode 100644 test/unit/Support/DdlAdapterTrait.php diff --git a/composer.json b/composer.json index 77477dd..ffcf92b 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,10 @@ ], "extra": { "laminas": { - "config-provider": "PhpDb\\WebwareProvider" + "config-provider": [ + "PhpDb\\WebwareProvider", + "PhpDb\\Session\\SessionProvider" + ] } }, "require": { @@ -27,7 +30,9 @@ }, "require-dev": { "infection/infection": "^0.35.0", + "laminas/laminas-diactoros": "^3.5", "laminas/laminas-servicemanager": "^4.5", + "mezzio/mezzio-session": "^1.0", "php-db/phpdb-mysql": "^0.5.x-dev", "php-db/phpdb-pgsql": "^0.1.x-dev", "php-db/phpdb-sqlite": "^0.3.x-dev", @@ -35,8 +40,13 @@ "phpunit/phpunit": "^13.3", "roave/backward-compatibility-check": "^8.21.0", "roave/security-advisories": "dev-master", + "webware/webware-console": "1.0.x-dev", "webware/webware-tools": "^1.0.0-beta.3" }, + "suggest": { + "mezzio/mezzio-session": "Required to use the database-backed session persistence", + "webware/webware-console": "Provides the console that runs session:init-db to create the session table" + }, "autoload": { "psr-4": { "PhpDb\\": "src/" diff --git a/composer.lock b/composer.lock index faa2539..21c0828 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": "07377bce0fa437e217fed81e932ef050", + "content-hash": "3afa1b2e48ac9cd3b248b58581797af2", "packages": [ { "name": "brick/varexporter", @@ -1149,6 +1149,68 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "dflydev/fig-cookies", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-fig-cookies.git", + "reference": "f9c63878e75483800477db4f897237b36556617b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-fig-cookies/zipball/f9c63878e75483800477db4f897237b36556617b", + "reference": "f9c63878e75483800477db4f897237b36556617b", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0.1 || ^2" + }, + "require-dev": { + "doctrine/coding-standard": "^8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12", + "phpstan/phpstan-phpunit": "^0.12.16", + "phpunit/phpunit": "^7.2.6 || ^9", + "scrutinizer/ocular": "^1.8", + "squizlabs/php_codesniffer": "^3.3", + "vimeo/psalm": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\FigCookies\\": "src/Dflydev/FigCookies" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Beau Simensen", + "email": "beau@dflydev.com" + } + ], + "description": "Cookies for PSR-7 HTTP Message Interface.", + "keywords": [ + "cookies", + "psr-7", + "psr7" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-fig-cookies/issues", + "source": "https://github.com/dflydev/dflydev-fig-cookies/tree/v3.2.0" + }, + "time": "2025-09-03T20:01:04+00:00" + }, { "name": "doctrine/annotations", "version": "2.0.2", @@ -1843,6 +1905,162 @@ }, "time": "2026-09-04T12:54:20+00:00" }, + { + "name": "laminas/laminas-config-aggregator", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-config-aggregator.git", + "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-config-aggregator/zipball/75331b17a9ba72c5fa8679988ef08a588ddd86af", + "reference": "75331b17a9ba72c5fa8679988ef08a588ddd86af", + "shasum": "" + }, + "require": { + "brick/varexporter": "^0.5.0 || ^0.4.0 || ^0.6.0 || ^0.7.0", + "laminas/laminas-stdlib": "^3.18.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "webimpress/safe-writer": "^2.2.0" + }, + "conflict": { + "nikic/php-parser": "<4.12", + "zendframework/zend-config-aggregator": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-config": "^3.10.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "laminas/laminas-config": "Allows loading configuration from XML, INI, YAML, and JSON files", + "laminas/laminas-config-aggregator-modulemanager": "Allows loading configuration from laminas-mvc Module classes", + "laminas/laminas-config-aggregator-parameters": "Allows usage of templated parameters within your configuration" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\ConfigAggregator\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Lightweight library for collecting and merging configuration from different sources", + "homepage": "https://laminas.dev", + "keywords": [ + "config-aggregator", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-config-aggregator/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-config-aggregator/issues", + "rss": "https://github.com/laminas/laminas-config-aggregator/releases.atom", + "source": "https://github.com/laminas/laminas-config-aggregator" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2026-06-01T15:37:55+00:00" + }, + { + "name": "laminas/laminas-diactoros", + "version": "3.8.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-diactoros.git", + "reference": "60c182916b2749480895601649563970f3f12ec4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", + "reference": "60c182916b2749480895601649563970f3f12ec4", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "conflict": { + "amphp/amp": "<2.6.4" + }, + "provide": { + "psr/http-factory-implementation": "^1.0", + "psr/http-message-implementation": "^1.1 || ^2.0" + }, + "require-dev": { + "ext-curl": "*", + "ext-dom": "*", + "ext-gd": "*", + "ext-libxml": "*", + "http-interop/http-factory-tests": "^2.2.0", + "laminas/laminas-coding-standard": "~3.1.0", + "php-http/psr7-integration-tests": "^1.4.0", + "phpunit/phpunit": "^10.5.36", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13" + }, + "type": "library", + "extra": { + "laminas": { + "module": "Laminas\\Diactoros", + "config-provider": "Laminas\\Diactoros\\ConfigProvider" + } + }, + "autoload": { + "files": [ + "src/functions/create_uploaded_file.php", + "src/functions/marshal_headers_from_sapi.php", + "src/functions/marshal_method_from_sapi.php", + "src/functions/marshal_protocol_version_from_sapi.php", + "src/functions/normalize_server.php", + "src/functions/normalize_uploaded_files.php", + "src/functions/parse_cookie_header.php" + ], + "psr-4": { + "Laminas\\Diactoros\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "PSR HTTP Message implementations", + "homepage": "https://laminas.dev", + "keywords": [ + "http", + "laminas", + "psr", + "psr-17", + "psr-7" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-diactoros/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-diactoros/issues", + "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", + "source": "https://github.com/laminas/laminas-diactoros" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-12T15:31:36+00:00" + }, { "name": "marc-mabe/php-enum", "version": "v4.7.2", @@ -1916,6 +2134,82 @@ }, "time": "2025-09-14T11:18:39+00:00" }, + { + "name": "mezzio/mezzio-session", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/mezzio/mezzio-session.git", + "reference": "215095b4445283f2d527c1edc3338aa1ffbe3067" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mezzio/mezzio-session/zipball/215095b4445283f2d527c1edc3338aa1ffbe3067", + "reference": "215095b4445283f2d527c1edc3338aa1ffbe3067", + "shasum": "" + }, + "require": { + "dflydev/fig-cookies": "^3.0", + "ext-json": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-server-middleware": "^1.0" + }, + "conflict": { + "zendframework/zend-expressive-session": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "~3.1.0", + "laminas/laminas-diactoros": "^3.6.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "suggest": { + "mezzio/mezzio-csrf": "^1.0 || ^1.0-dev for CSRF protection capabilities", + "mezzio/mezzio-flash": "^1.0 || ^1.0-dev for flash message capabilities", + "mezzio/mezzio-session-ext": "^1.0 || ^1.0-dev for an ext-session persistence adapter" + }, + "type": "library", + "extra": { + "laminas": { + "config-provider": "Mezzio\\Session\\ConfigProvider" + } + }, + "autoload": { + "psr-4": { + "Mezzio\\Session\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Session container and middleware for PSR-7 applications", + "homepage": "https://mezzio.dev", + "keywords": [ + "laminas", + "mezzio", + "middleware", + "psr-7", + "session" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.mezzio.dev/mezzio-session/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/mezzio/mezzio-session/issues", + "rss": "https://github.com/mezzio/mezzio-session/releases.atom", + "source": "https://github.com/mezzio/mezzio-session" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T18:36:13+00:00" + }, { "name": "myclabs/deep-copy", "version": "1.14.0", @@ -2477,18 +2771,83 @@ }, "time": "2026-09-09T04:39:44+00:00" }, + { + "name": "php-standard-library/ansi", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-standard-library/ansi.git", + "reference": "c0e20cf6ec3caba4aaaa7502c7ad93b81f07d348" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-standard-library/ansi/zipball/c0e20cf6ec3caba4aaaa7502c7ad93b81f07d348", + "reference": "c0e20cf6ec3caba4aaaa7502c7ad93b81f07d348", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "php-standard-library/foundation": "^6.0" + }, + "conflict": { + "azjezz/psl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-next": "6.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/Psl/bootstrap.php" + ], + "psr-4": { + "Psl\\Ansi\\": "src/Psl/Ansi/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Seifeddine Gmati", + "email": "azjezz@carthage.software" + }, + { + "name": "Contributors", + "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" + } + ], + "description": "Pure functions for constructing ANSI escape sequences - text styling, colors, cursor movement, and screen manipulation", + "homepage": "https://php-standard-library.dev", + "keywords": [ + "ansi", + "colors", + "escape-sequences", + "terminal" + ], + "support": { + "source": "https://github.com/php-standard-library/ansi/tree/6.3.0" + }, + "time": "2026-09-25T15:54:11+00:00" + }, { "name": "php-standard-library/async", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/async.git", - "reference": "8c0c63d3e304318a7c78846044edc1dfef398d1d" + "reference": "3d2fbaf391cd256f5543f96889e9e738840d321a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/async/zipball/8c0c63d3e304318a7c78846044edc1dfef398d1d", - "reference": "8c0c63d3e304318a7c78846044edc1dfef398d1d", + "url": "https://api.github.com/repos/php-standard-library/async/zipball/3d2fbaf391cd256f5543f96889e9e738840d321a", + "reference": "3d2fbaf391cd256f5543f96889e9e738840d321a", "shasum": "" }, "require": { @@ -2511,7 +2870,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2545,22 +2904,22 @@ "cooperative-multitasking" ], "support": { - "source": "https://github.com/php-standard-library/async/tree/6.2.1" + "source": "https://github.com/php-standard-library/async/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/channel", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/channel.git", - "reference": "1d90ea4e262978a583ad24c2c9a97c9daa26b6f1" + "reference": "f2f5f0ee4d04dccc57d33e6620671c6296e881e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/channel/zipball/1d90ea4e262978a583ad24c2c9a97c9daa26b6f1", - "reference": "1d90ea4e262978a583ad24c2c9a97c9daa26b6f1", + "url": "https://api.github.com/repos/php-standard-library/channel/zipball/f2f5f0ee4d04dccc57d33e6620671c6296e881e2", + "reference": "f2f5f0ee4d04dccc57d33e6620671c6296e881e2", "shasum": "" }, "require": { @@ -2580,7 +2939,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2614,9 +2973,9 @@ "message-passing" ], "support": { - "source": "https://github.com/php-standard-library/channel/tree/6.2.1" + "source": "https://github.com/php-standard-library/channel/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/collection", @@ -2686,16 +3045,16 @@ }, { "name": "php-standard-library/comparison", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/comparison.git", - "reference": "1884e2218c231c285b6039e9b2010f5dd956ae20" + "reference": "e77454e109feaa13732a3d556e1f8855b216b4fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/comparison/zipball/1884e2218c231c285b6039e9b2010f5dd956ae20", - "reference": "1884e2218c231c285b6039e9b2010f5dd956ae20", + "url": "https://api.github.com/repos/php-standard-library/comparison/zipball/e77454e109feaa13732a3d556e1f8855b216b4fd", + "reference": "e77454e109feaa13732a3d556e1f8855b216b4fd", "shasum": "" }, "require": { @@ -2712,7 +3071,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2745,22 +3104,22 @@ "ordering" ], "support": { - "source": "https://github.com/php-standard-library/comparison/tree/6.2.1" + "source": "https://github.com/php-standard-library/comparison/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/date-time", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/date-time.git", - "reference": "1e5e2b51eb2b27c2933859872e7c4a45abc5226e" + "reference": "4d280c6192e1b17ee8a59e07ed0fc29129bcd373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/date-time/zipball/1e5e2b51eb2b27c2933859872e7c4a45abc5226e", - "reference": "1e5e2b51eb2b27c2933859872e7c4a45abc5226e", + "url": "https://api.github.com/repos/php-standard-library/date-time/zipball/4d280c6192e1b17ee8a59e07ed0fc29129bcd373", + "reference": "4d280c6192e1b17ee8a59e07ed0fc29129bcd373", "shasum": "" }, "require": { @@ -2783,7 +3142,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2817,22 +3176,22 @@ "timezone" ], "support": { - "source": "https://github.com/php-standard-library/date-time/tree/6.2.1" + "source": "https://github.com/php-standard-library/date-time/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/default", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/default.git", - "reference": "89f05ec6e6a29e8c07de7b6755d14d05b06048e2" + "reference": "6f7daf0864b4f319dee645cb0ffd7c4be0c9907c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/default/zipball/89f05ec6e6a29e8c07de7b6755d14d05b06048e2", - "reference": "89f05ec6e6a29e8c07de7b6755d14d05b06048e2", + "url": "https://api.github.com/repos/php-standard-library/default/zipball/6f7daf0864b4f319dee645cb0ffd7c4be0c9907c", + "reference": "6f7daf0864b4f319dee645cb0ffd7c4be0c9907c", "shasum": "" }, "require": { @@ -2847,7 +3206,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -2876,9 +3235,9 @@ "interface" ], "support": { - "source": "https://github.com/php-standard-library/default/tree/6.2.1" + "source": "https://github.com/php-standard-library/default/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/dict", @@ -2951,16 +3310,16 @@ }, { "name": "php-standard-library/either-or-both", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/either-or-both.git", - "reference": "a1f4d80ee5ee616272688941390d2eae2d1948e6" + "reference": "cd443b661dae1dc87f309e17953e9a7426690195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/either-or-both/zipball/a1f4d80ee5ee616272688941390d2eae2d1948e6", - "reference": "a1f4d80ee5ee616272688941390d2eae2d1948e6", + "url": "https://api.github.com/repos/php-standard-library/either-or-both/zipball/cd443b661dae1dc87f309e17953e9a7426690195", + "reference": "cd443b661dae1dc87f309e17953e9a7426690195", "shasum": "" }, "require": { @@ -2979,7 +3338,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3014,9 +3373,9 @@ "union-type" ], "support": { - "source": "https://github.com/php-standard-library/either-or-both/tree/6.2.1" + "source": "https://github.com/php-standard-library/either-or-both/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/env", @@ -3224,16 +3583,16 @@ }, { "name": "php-standard-library/foundation", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/foundation.git", - "reference": "7f65cec48c8ed3d53dc8dd7643796e2ba6de6008" + "reference": "9b464388f93b5c496bd6a17912e5cdc846b9df1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/foundation/zipball/7f65cec48c8ed3d53dc8dd7643796e2ba6de6008", - "reference": "7f65cec48c8ed3d53dc8dd7643796e2ba6de6008", + "url": "https://api.github.com/repos/php-standard-library/foundation/zipball/9b464388f93b5c496bd6a17912e5cdc846b9df1e", + "reference": "9b464388f93b5c496bd6a17912e5cdc846b9df1e", "shasum": "" }, "require": { @@ -3248,7 +3607,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3281,22 +3640,22 @@ "invariant" ], "support": { - "source": "https://github.com/php-standard-library/foundation/tree/6.2.1" + "source": "https://github.com/php-standard-library/foundation/tree/6.3.0" }, - "time": "2026-04-28T06:28:49+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/interoperability", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/interoperability.git", - "reference": "09ae66cc3e6463538c3a4592bb8762cf60113821" + "reference": "d95ae8d2502d05b7aa14e456b2c7f5e9c1510228" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/interoperability/zipball/09ae66cc3e6463538c3a4592bb8762cf60113821", - "reference": "09ae66cc3e6463538c3a4592bb8762cf60113821", + "url": "https://api.github.com/repos/php-standard-library/interoperability/zipball/d95ae8d2502d05b7aa14e456b2c7f5e9c1510228", + "reference": "d95ae8d2502d05b7aa14e456b2c7f5e9c1510228", "shasum": "" }, "require": { @@ -3311,7 +3670,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3340,22 +3699,22 @@ "interoperability" ], "support": { - "source": "https://github.com/php-standard-library/interoperability/tree/6.2.1" + "source": "https://github.com/php-standard-library/interoperability/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/io", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/io.git", - "reference": "0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c" + "reference": "cd55c50f5158871c9a77b3fc6a1fca14f3d4d082" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/io/zipball/0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c", - "reference": "0b61f6b5e0a392c52d3cb2b386d8bba379b91f3c", + "url": "https://api.github.com/repos/php-standard-library/io/zipball/cd55c50f5158871c9a77b3fc6a1fca14f3d4d082", + "reference": "cd55c50f5158871c9a77b3fc6a1fca14f3d4d082", "shasum": "" }, "require": { @@ -3378,7 +3737,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3412,22 +3771,22 @@ "stream" ], "support": { - "source": "https://github.com/php-standard-library/io/tree/6.2.1" + "source": "https://github.com/php-standard-library/io/tree/6.3.0" }, - "time": "2026-04-28T03:11:45+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/iter", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/iter.git", - "reference": "d3f2db3adec4cbe5129f3428969ae8508a54aeb7" + "reference": "9a53e9e4614b6e30ac23df21bd9bf7540dad79f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/iter/zipball/d3f2db3adec4cbe5129f3428969ae8508a54aeb7", - "reference": "d3f2db3adec4cbe5129f3428969ae8508a54aeb7", + "url": "https://api.github.com/repos/php-standard-library/iter/zipball/9a53e9e4614b6e30ac23df21bd9bf7540dad79f8", + "reference": "9a53e9e4614b6e30ac23df21bd9bf7540dad79f8", "shasum": "" }, "require": { @@ -3450,7 +3809,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3484,9 +3843,9 @@ "iterator" ], "support": { - "source": "https://github.com/php-standard-library/iter/tree/6.2.1" + "source": "https://github.com/php-standard-library/iter/tree/6.3.0" }, - "time": "2026-04-28T06:28:49+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/json", @@ -3559,16 +3918,16 @@ }, { "name": "php-standard-library/locale", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/locale.git", - "reference": "0771cfb19757e8923c66b7f46ab90280775caecd" + "reference": "9687409700fc2392046f43cc4c1494253f8cf178" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/locale/zipball/0771cfb19757e8923c66b7f46ab90280775caecd", - "reference": "0771cfb19757e8923c66b7f46ab90280775caecd", + "url": "https://api.github.com/repos/php-standard-library/locale/zipball/9687409700fc2392046f43cc4c1494253f8cf178", + "reference": "9687409700fc2392046f43cc4c1494253f8cf178", "shasum": "" }, "require": { @@ -3585,7 +3944,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3615,22 +3974,89 @@ "locale" ], "support": { - "source": "https://github.com/php-standard-library/locale/tree/6.2.1" + "source": "https://github.com/php-standard-library/locale/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" + }, + { + "name": "php-standard-library/math", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-standard-library/math.git", + "reference": "7ed71a7ed487a32d136b5f38ffe7cffc206bbdfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-standard-library/math/zipball/7ed71a7ed487a32d136b5f38ffe7cffc206bbdfe", + "reference": "7ed71a7ed487a32d136b5f38ffe7cffc206bbdfe", + "shasum": "" + }, + "require": { + "ext-bcmath": "*", + "php": "~8.4.0 || ~8.5.0", + "php-standard-library/foundation": "^6.0" + }, + "conflict": { + "azjezz/psl": "*" + }, + "require-dev": { + "php-standard-library/iter": "^6.0", + "php-standard-library/str": "^6.0", + "php-standard-library/vec": "^6.0", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-next": "6.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/Psl/bootstrap.php" + ], + "psr-4": { + "Psl\\Math\\": "src/Psl/Math/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Seifeddine Gmati", + "email": "azjezz@carthage.software" + }, + { + "name": "Contributors", + "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" + } + ], + "description": "Mathematical functions with strict typing and predictable error handling", + "homepage": "https://php-standard-library.dev", + "keywords": [ + "math", + "mathematics" + ], + "support": { + "source": "https://github.com/php-standard-library/math/tree/6.3.0" + }, + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/option", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/option.git", - "reference": "afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66" + "reference": "d6cfc43ae2d0ce122d861065f5e6a433737b9e93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/option/zipball/afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66", - "reference": "afe2059dbb3a9e2e3ba6edd371ccbb7d87355a66", + "url": "https://api.github.com/repos/php-standard-library/option/zipball/d6cfc43ae2d0ce122d861065f5e6a433737b9e93", + "reference": "d6cfc43ae2d0ce122d861065f5e6a433737b9e93", "shasum": "" }, "require": { @@ -3647,7 +4073,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3681,22 +4107,86 @@ "some" ], "support": { - "source": "https://github.com/php-standard-library/option/tree/6.2.1" + "source": "https://github.com/php-standard-library/option/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" + }, + { + "name": "php-standard-library/os", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-standard-library/os.git", + "reference": "602a4bd323e7f7d70c727b99759864d28f1058b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-standard-library/os/zipball/602a4bd323e7f7d70c727b99759864d28f1058b9", + "reference": "602a4bd323e7f7d70c727b99759864d28f1058b9", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "php-standard-library/default": "^6.0" + }, + "conflict": { + "azjezz/psl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-next": "6.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/Psl/bootstrap.php" + ], + "psr-4": { + "Psl\\OS\\": "src/Psl/OS/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Seifeddine Gmati", + "email": "azjezz@carthage.software" + }, + { + "name": "Contributors", + "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" + } + ], + "description": "Type-safe operating system detection via OperatingSystemFamily enum", + "homepage": "https://php-standard-library.dev", + "keywords": [ + "operating-system", + "os", + "platform" + ], + "support": { + "source": "https://github.com/php-standard-library/os/tree/6.3.0" + }, + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/process", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/process.git", - "reference": "ab41e8b8de07289971f447ce289ec1effde321f3" + "reference": "46895e2c1b19cdd6c48688d1c631f1c23bc524e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/process/zipball/ab41e8b8de07289971f447ce289ec1effde321f3", - "reference": "ab41e8b8de07289971f447ce289ec1effde321f3", + "url": "https://api.github.com/repos/php-standard-library/process/zipball/46895e2c1b19cdd6c48688d1c631f1c23bc524e5", + "reference": "46895e2c1b19cdd6c48688d1c631f1c23bc524e5", "shasum": "" }, "require": { @@ -3718,7 +4208,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3748,22 +4238,22 @@ "subprocess" ], "support": { - "source": "https://github.com/php-standard-library/process/tree/6.2.1" + "source": "https://github.com/php-standard-library/process/tree/6.3.0" }, - "time": "2026-05-23T20:46:16+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/promise", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/promise.git", - "reference": "a38a2694e06609874e856e68951bca24ad00009c" + "reference": "97e5c6ffac07058f0ae408c747b5f5c363f3673b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/promise/zipball/a38a2694e06609874e856e68951bca24ad00009c", - "reference": "a38a2694e06609874e856e68951bca24ad00009c", + "url": "https://api.github.com/repos/php-standard-library/promise/zipball/97e5c6ffac07058f0ae408c747b5f5c363f3673b", + "reference": "97e5c6ffac07058f0ae408c747b5f5c363f3673b", "shasum": "" }, "require": { @@ -3778,7 +4268,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3808,22 +4298,22 @@ "promise" ], "support": { - "source": "https://github.com/php-standard-library/promise/tree/6.2.1" + "source": "https://github.com/php-standard-library/promise/tree/6.3.0" }, - "time": "2026-04-26T16:26:10+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/range", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/range.git", - "reference": "34befd85fa3e048972aabac7cdf6a86e2f4b833f" + "reference": "9e08b73482c2c8ee6065e4eb1c6d138ceba56376" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/range/zipball/34befd85fa3e048972aabac7cdf6a86e2f4b833f", - "reference": "34befd85fa3e048972aabac7cdf6a86e2f4b833f", + "url": "https://api.github.com/repos/php-standard-library/range/zipball/9e08b73482c2c8ee6065e4eb1c6d138ceba56376", + "reference": "9e08b73482c2c8ee6065e4eb1c6d138ceba56376", "shasum": "" }, "require": { @@ -3841,7 +4331,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -3873,9 +4363,9 @@ "range" ], "support": { - "source": "https://github.com/php-standard-library/range/tree/6.2.1" + "source": "https://github.com/php-standard-library/range/tree/6.3.0" }, - "time": "2026-03-28T18:39:47+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/regex", @@ -3944,16 +4434,16 @@ }, { "name": "php-standard-library/result", - "version": "6.2.1", + "version": "6.3.0", "source": { "type": "git", "url": "https://github.com/php-standard-library/result.git", - "reference": "242bbf872662f5091a37d7db9d45f119225a01f5" + "reference": "8489291e58eb926d3ffc9e48b23d8fa6678a0ff3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/result/zipball/242bbf872662f5091a37d7db9d45f119225a01f5", - "reference": "242bbf872662f5091a37d7db9d45f119225a01f5", + "url": "https://api.github.com/repos/php-standard-library/result/zipball/8489291e58eb926d3ffc9e48b23d8fa6678a0ff3", + "reference": "8489291e58eb926d3ffc9e48b23d8fa6678a0ff3", "shasum": "" }, "require": { @@ -3971,7 +4461,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -4004,9 +4494,9 @@ "result" ], "support": { - "source": "https://github.com/php-standard-library/result/tree/6.2.1" + "source": "https://github.com/php-standard-library/result/tree/6.3.0" }, - "time": "2026-04-26T16:26:10+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/shell", @@ -4043,7 +4533,74 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Psl/bootstrap.php" + ], + "psr-4": { + "Psl\\Shell\\": "src/Psl/Shell/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Seifeddine Gmati", + "email": "azjezz@carthage.software" + }, + { + "name": "Contributors", + "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" + } + ], + "description": "Shell command execution with argument escaping and error output management", + "homepage": "https://php-standard-library.dev", + "keywords": [ + "command", + "execute", + "shell" + ], + "support": { + "source": "https://github.com/php-standard-library/shell/tree/6.2.1" + }, + "time": "2026-05-23T20:26:52+00:00" + }, + { + "name": "php-standard-library/str", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-standard-library/str.git", + "reference": "ab722aecf6c45531cdf0bf8ee11aa2370d71dff7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-standard-library/str/zipball/ab722aecf6c45531cdf0bf8ee11aa2370d71dff7", + "reference": "ab722aecf6c45531cdf0bf8ee11aa2370d71dff7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "~8.4.0 || ~8.5.0", + "php-standard-library/default": "^6.0", + "php-standard-library/foundation": "^6.0", + "php-standard-library/range": "^6.0" + }, + "conflict": { + "azjezz/psl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -4051,7 +4608,7 @@ "src/Psl/bootstrap.php" ], "psr-4": { - "Psl\\Shell\\": "src/Psl/Shell/" + "Psl\\Str\\": "src/Psl/Str/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4068,38 +4625,44 @@ "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" } ], - "description": "Shell command execution with argument escaping and error output management", + "description": "Unicode-aware string functions replacing PHP mb_* and standard string functions", "homepage": "https://php-standard-library.dev", "keywords": [ - "command", - "execute", - "shell" + "mbstring", + "string", + "unicode", + "utf8" ], "support": { - "source": "https://github.com/php-standard-library/shell/tree/6.2.1" + "source": "https://github.com/php-standard-library/str/tree/6.3.0" }, - "time": "2026-05-23T20:26:52+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { - "name": "php-standard-library/str", - "version": "6.2.1", + "name": "php-standard-library/terminal", + "version": "6.3.0", "source": { "type": "git", - "url": "https://github.com/php-standard-library/str.git", - "reference": "e88e7c79e1c07227aa3c50005a3a91e88425e56a" + "url": "https://github.com/php-standard-library/terminal.git", + "reference": "22ea2db5532d65d13f51dde97a4ee0d01f8083bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-standard-library/str/zipball/e88e7c79e1c07227aa3c50005a3a91e88425e56a", - "reference": "e88e7c79e1c07227aa3c50005a3a91e88425e56a", + "url": "https://api.github.com/repos/php-standard-library/terminal/zipball/22ea2db5532d65d13f51dde97a4ee0d01f8083bf", + "reference": "22ea2db5532d65d13f51dde97a4ee0d01f8083bf", "shasum": "" }, "require": { - "ext-mbstring": "*", "php": "~8.4.0 || ~8.5.0", - "php-standard-library/default": "^6.0", + "php-standard-library/ansi": "^6.0", + "php-standard-library/async": "^6.0", + "php-standard-library/date-time": "^6.0", "php-standard-library/foundation": "^6.0", - "php-standard-library/range": "^6.0" + "php-standard-library/io": "^6.0", + "php-standard-library/math": "^6.0", + "php-standard-library/os": "^6.0", + "php-standard-library/process": "^6.0", + "php-standard-library/str": "^6.0" }, "conflict": { "azjezz/psl": "*" @@ -4110,7 +4673,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-next": "6.2.x-dev" + "dev-next": "6.3.x-dev" } }, "autoload": { @@ -4118,7 +4681,7 @@ "src/Psl/bootstrap.php" ], "psr-4": { - "Psl\\Str\\": "src/Psl/Str/" + "Psl\\Terminal\\": "src/Psl/Terminal/" } }, "notification-url": "https://packagist.org/downloads/", @@ -4135,18 +4698,18 @@ "homepage": "https://github.com/php-standard-library/php-standard-library/graphs/contributors" } ], - "description": "Unicode-aware string functions replacing PHP mb_* and standard string functions", + "description": "Full-featured TUI framework with widgets, events, and diff-based rendering", "homepage": "https://php-standard-library.dev", "keywords": [ - "mbstring", - "string", - "unicode", - "utf8" + "console", + "terminal", + "tui", + "widgets" ], "support": { - "source": "https://github.com/php-standard-library/str/tree/6.2.1" + "source": "https://github.com/php-standard-library/terminal/tree/6.3.0" }, - "time": "2026-04-29T06:23:35+00:00" + "time": "2026-09-25T15:54:11+00:00" }, { "name": "php-standard-library/type", @@ -5012,6 +5575,227 @@ }, "time": "2022-11-25T14:36:26+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -8117,16 +8901,16 @@ }, { "name": "symfony/console", - "version": "v7.4.19", + "version": "v7.4.20", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "3a1973458b153f566a8d7499bc06ece323d60063" + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063", - "reference": "3a1973458b153f566a8d7499bc06ece323d60063", + "url": "https://api.github.com/repos/symfony/console/zipball/d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", + "reference": "d7bca2fe05241dc7732d0c8b6d551b879f5d0a7d", "shasum": "" }, "require": { @@ -8191,7 +8975,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.19" + "source": "https://github.com/symfony/console/tree/v7.4.20" }, "funding": [ { @@ -8211,7 +8995,7 @@ "type": "tidelift" } ], - "time": "2026-09-13T10:38:41+00:00" + "time": "2026-09-18T15:55:00+00:00" }, { "name": "symfony/deprecation-contracts", @@ -8579,16 +9363,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/5cac8f4db97208823aa353f59a01faad2da52c75", + "reference": "5cac8f4db97208823aa353f59a01faad2da52c75", "shasum": "" }, "require": { @@ -8637,7 +9421,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.43.0" }, "funding": [ { @@ -8657,20 +9441,20 @@ "type": "tidelift" } ], - "time": "2026-07-28T08:25:59+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.42.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", - "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/ebd57ccc7edb93d632e184efbe7693d2517bab3b", + "reference": "ebd57ccc7edb93d632e184efbe7693d2517bab3b", "shasum": "" }, "require": { @@ -8722,7 +9506,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.43.0" }, "funding": [ { @@ -8742,20 +9526,20 @@ "type": "tidelift" } ], - "time": "2026-08-07T06:33:24+00:00" + "time": "2026-09-23T22:02:09+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.2", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", - "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", + "reference": "4d5d331bf731e0aedbc6b2f5e61135b7b7996a72", "shasum": "" }, "require": { @@ -8807,7 +9591,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.43.0" }, "funding": [ { @@ -8827,7 +9611,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:59:30+00:00" + "time": "2026-09-24T15:19:04+00:00" }, { "name": "symfony/polyfill-php73", @@ -8911,16 +9695,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -8971,7 +9755,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -8991,7 +9775,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php81", @@ -9155,16 +9939,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.41.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", - "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/86a42cbcc07adc331481a5ab310a4024bfc227f6", + "reference": "86a42cbcc07adc331481a5ab310a4024bfc227f6", "shasum": "" }, "require": { @@ -9211,7 +9995,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.43.0" }, "funding": [ { @@ -9231,7 +10015,7 @@ "type": "tidelift" } ], - "time": "2026-07-01T12:47:55+00:00" + "time": "2026-09-25T15:46:47+00:00" }, { "name": "symfony/process", @@ -9668,6 +10452,65 @@ ], "time": "2025-12-08T11:19:18+00:00" }, + { + "name": "webimpress/safe-writer", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/webimpress/safe-writer.git", + "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webimpress/safe-writer/zipball/9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", + "reference": "9d37cc8bee20f7cb2f58f6e23e05097eab5072e6", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.4", + "vimeo/psalm": "^4.7", + "webimpress/coding-standard": "^1.2.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev", + "dev-develop": "2.3.x-dev", + "dev-release-1.0": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Webimpress\\SafeWriter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "description": "Tool to write files safely, to avoid race conditions", + "keywords": [ + "concurrent write", + "file writer", + "race condition", + "safe writer", + "webimpress" + ], + "support": { + "issues": "https://github.com/webimpress/safe-writer/issues", + "source": "https://github.com/webimpress/safe-writer/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/michalbundyra", + "type": "github" + } + ], + "time": "2021-04-19T16:34:45+00:00" + }, { "name": "webmozart/assert", "version": "2.4.1", @@ -9783,6 +10626,67 @@ }, "time": "2024-03-07T20:33:40+00:00" }, + { + "name": "webware/webware-console", + "version": "1.0.x-dev", + "source": { + "type": "git", + "url": "https://github.com/webinertia/webware-console.git", + "reference": "242e30e31ec41d35cb1942772fea65bdc39b3b70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webinertia/webware-console/zipball/242e30e31ec41d35cb1942772fea65bdc39b3b70", + "reference": "242e30e31ec41d35cb1942772fea65bdc39b3b70", + "shasum": "" + }, + "require": { + "laminas/laminas-config-aggregator": "^1.20", + "laminas/laminas-servicemanager": "^4.5", + "php": "~8.4.1 || ~8.5.0", + "php-standard-library/terminal": "^6.2", + "symfony/console": "^7.4 || ^8.0" + }, + "require-dev": { + "infection/infection": "^0.35", + "phpunit/phpunit": "^13.3", + "webware/webware-tools": "^1.0.0-beta.2" + }, + "default-branch": true, + "bin": [ + "bin/webware" + ], + "type": "library", + "autoload": { + "psr-4": { + "Webware\\Console\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Joey Smith", + "email": "jsmith@webinertia.net", + "role": "Developer" + } + ], + "description": "Webware Console — TUI application providing a menu and better help for Webware and Mezzio CLI commands", + "keywords": [ + "cli", + "console", + "mezzio", + "tui", + "webware" + ], + "support": { + "issues": "https://github.com/webinertia/webware-console/issues", + "source": "https://github.com/webinertia/webware-console/tree/1.0.x" + }, + "time": "2026-09-29T05:26:25+00:00" + }, { "name": "webware/webware-tools", "version": "1.0.0-beta.6", @@ -9835,7 +10739,8 @@ "php-db/phpdb-mysql": 20, "php-db/phpdb-pgsql": 20, "php-db/phpdb-sqlite": 20, - "roave/security-advisories": 20 + "roave/security-advisories": 20, + "webware/webware-console": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/src/Session/Console/Container/InitDbCommandFactory.php b/src/Session/Console/Container/InitDbCommandFactory.php new file mode 100644 index 0000000..6b6d2c3 --- /dev/null +++ b/src/Session/Console/Container/InitDbCommandFactory.php @@ -0,0 +1,39 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Console\Container; + +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\Console\InitDbCommand; +use PhpDb\Session\SessionTable; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Console\Exception\LogicException; + +final readonly class InitDbCommandFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws LogicException + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): InitDbCommand + { + return new InitDbCommand( + adapter: $container->get(AdapterInterface::class), + table : SessionTable::Session->value, + ); + } +} diff --git a/src/Session/Console/Ddl/Column/MediumText.php b/src/Session/Console/Ddl/Column/MediumText.php new file mode 100644 index 0000000..475fff4 --- /dev/null +++ b/src/Session/Console/Ddl/Column/MediumText.php @@ -0,0 +1,27 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Console\Ddl\Column; + +use PhpDb\Sql\Ddl\Column\Text; + +/** + * phpdb ships TEXT but no MEDIUMTEXT; serialized session data can exceed 64 KB. + * + * @internal + */ +final class MediumText extends Text +{ + protected string $type = 'MEDIUMTEXT'; +} diff --git a/src/Session/Console/InitDbCommand.php b/src/Session/Console/InitDbCommand.php new file mode 100644 index 0000000..55a6197 --- /dev/null +++ b/src/Session/Console/InitDbCommand.php @@ -0,0 +1,97 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Console; + +use Override; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\Console\Schema\SessionSchema; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\DropTable; +use PhpDb\Sql\Exception\InvalidArgumentException as SqlInvalidArgumentException; +use PhpDb\Sql\Sql; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidArgumentException as ConsoleInvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException as ConsoleLogicException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand( + name : 'session:init-db', + description: 'Create the table the database session persistence writes to', +)] +final class InitDbCommand extends Command +{ + private readonly SessionSchema $schema; + + /** + * @throws ConsoleLogicException + */ + public function __construct( + private readonly AdapterInterface $adapter, + private readonly string $table, + ) { + $this->schema = new SessionSchema(); + + parent::__construct(); + } + + /** + * @throws ConsoleInvalidArgumentException + */ + #[Override] + protected function configure(): void + { + $this->addOption( + name : 'drop', + mode : InputOption::VALUE_NONE, + description: 'Drop the session table before recreating it', + ); + } + + /** + * @throws ConsoleInvalidArgumentException + * @throws SqlInvalidArgumentException + */ + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sql = new Sql($this->adapter); + + if (true === $input->getOption('drop')) { + $output->writeln("Dropping table {$this->table}..."); + $this->executeDdl($sql, $this->schema->dropTable($this->table)); + } + + $output->writeln("Creating table {$this->table}..."); + $this->executeDdl($sql, $this->schema->sessionTable($this->table)); + + $output->writeln('Session table initialized.'); + + return Command::SUCCESS; + } + + /** + * @throws SqlInvalidArgumentException + */ + private function executeDdl(Sql $sql, CreateTable|DropTable $ddl): void + { + $this->adapter->query( + $sql->buildSqlString($ddl), + AdapterInterface::QUERY_MODE_EXECUTE, + ); + } +} diff --git a/src/Session/Console/Schema/SessionSchema.php b/src/Session/Console/Schema/SessionSchema.php new file mode 100644 index 0000000..0c0fc55 --- /dev/null +++ b/src/Session/Console/Schema/SessionSchema.php @@ -0,0 +1,78 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Console\Schema; + +use PhpDb\Session\Console\Ddl\Column\MediumText; +use PhpDb\Sql\Ddl\Column\Datetime; +use PhpDb\Sql\Ddl\Column\Varchar; +use PhpDb\Sql\Ddl\Constraint\PrimaryKey; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\DropTable; +use PhpDb\Sql\Ddl\Index\Index; +use PhpDb\Sql\Literal; + +/** + * Builds the table {@see \PhpDb\Session\DbSessionHandler} and + * {@see \PhpDb\Session\PhpDbSessionPersistence} read and write. + * + * The columns are the four both classes use. modified_at carries no database default because + * every write supplies it. + * + * @internal + */ +final class SessionSchema +{ + public function dropTable(string $table): DropTable + { + return new DropTable(table: $table)->ifExists(); + } + + public function sessionTable(string $table): CreateTable + { + $createTable = new CreateTable(table: $table)->ifNotExists(); + + $createTable->addColumn(new Varchar( + name : 'id', + length : 64, + nullable: false, + )); + $createTable->addColumn(new MediumText( + name : 'payload', + nullable: false, + )); + $createTable->addColumn(new Datetime( + name : 'modified_at', + nullable: false, + )); + $createTable->addColumn(new Datetime( + name : 'expires_at', + nullable: false, + )); + + $createTable->addConstraint(new PrimaryKey(columns: 'id')); + $createTable->addConstraint(new Index( + columns: 'expires_at', + name : 'idx_expires_at', + )); + + $createTable->setOptions(options: [ + 'engine' => new Literal(literal: 'InnoDB'), + 'default charset' => new Literal(literal: 'utf8mb4'), + 'collate' => new Literal(literal: 'utf8mb4_unicode_ci'), + ]); + + return $createTable; + } +} diff --git a/src/Session/Container/DbSessionHandlerFactory.php b/src/Session/Container/DbSessionHandlerFactory.php new file mode 100644 index 0000000..96cd9a7 --- /dev/null +++ b/src/Session/Container/DbSessionHandlerFactory.php @@ -0,0 +1,35 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Container; + +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\DbSessionHandler; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; + +final class DbSessionHandlerFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): DbSessionHandler + { + return new DbSessionHandler( + $container->get(AdapterInterface::class), + ); + } +} diff --git a/src/Session/Container/PhpDbSessionPersistenceFactory.php b/src/Session/Container/PhpDbSessionPersistenceFactory.php new file mode 100644 index 0000000..f61e0fd --- /dev/null +++ b/src/Session/Container/PhpDbSessionPersistenceFactory.php @@ -0,0 +1,42 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Container; + +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\PhpDbSessionPersistence; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; + +/** + * @phpstan-import-type SessionSettings from PhpDbSessionPersistence + */ +final class PhpDbSessionPersistenceFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): PhpDbSessionPersistence + { + /** @var array{session?: SessionSettings} $config */ + $config = $container->has('config') ? $container->get('config') : []; + + return new PhpDbSessionPersistence( + adapter : $container->get(AdapterInterface::class), + sessionConfig: $config['session'] ?? [], + ); + } +} diff --git a/src/Session/DbSessionHandler.php b/src/Session/DbSessionHandler.php new file mode 100644 index 0000000..883652e --- /dev/null +++ b/src/Session/DbSessionHandler.php @@ -0,0 +1,127 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use Override; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\Sql\Insert; +use PhpDb\Sql\Exception\InvalidArgumentException as SqlInvalidArgumentException; +use PhpDb\Sql\Sql; +use SessionHandlerInterface; + +use function date; +use function ini_get; +use function is_array; +use function is_string; +use function time; + +final class DbSessionHandler implements SessionHandlerInterface +{ + private const string TIMESTAMP_FORMAT = 'Y-m-d H:i:s'; + + private readonly Sql $sql; + + public function __construct(AdapterInterface $adapter) + { + $this->sql = new Sql( + adapter: $adapter, + table : SessionTable::Session->value, + ); + } + + #[Override] + public function close(): bool + { + return true; + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function destroy(string $id): bool + { + $delete = $this->sql->delete()->where(['id' => $id]); + + $this->sql->prepareStatementForSqlObject($delete)->execute(); + + return true; + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function gc(int $maxLifetime): int|false + { + $cutoff = date(format: self::TIMESTAMP_FORMAT); + + $delete = $this->sql->delete(); + $delete->where->lessThan('expires_at', $cutoff); + + $result = $this->sql->prepareStatementForSqlObject($delete)->execute(); + + return null === $result ? false : $result->getAffectedRows(); + } + + #[Override] + public function open(string $path, string $name): bool + { + return true; + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function read(string $id): string|false + { + $select = $this->sql->select() + ->columns(['payload']) + ->where(['id' => $id]); + $select->where->greaterThan('expires_at', date(format: self::TIMESTAMP_FORMAT)); + + $row = $this->sql->prepareStatementForSqlObject($select)->execute()?->current(); + $payload = is_array($row) ? $row['payload'] ?? false : false; + + return is_string($payload) ? $payload : false; + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function write(string $id, string $data): bool + { + $maxLifetime = (int) ini_get(option: 'session.gc_maxlifetime'); + $expiresAt = date( + format : self::TIMESTAMP_FORMAT, + timestamp: time() + $maxLifetime, + ); + $now = date(format: self::TIMESTAMP_FORMAT); + + $this->sql->prepareStatementForSqlObject( + new Insert(table: SessionTable::Session->value)->values([ + 'id' => $id, + 'payload' => $data, + 'expires_at' => $expiresAt, + 'modified_at' => $now, + ]), + ) + ->execute(); + + return true; + } +} diff --git a/src/Session/IniDefaults.php b/src/Session/IniDefaults.php new file mode 100644 index 0000000..34f657d --- /dev/null +++ b/src/Session/IniDefaults.php @@ -0,0 +1,120 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use Closure; + +use function filter_var; +use function ini_get; + +use const FILTER_NULL_ON_FAILURE; +use const FILTER_VALIDATE_BOOLEAN; + +/** + * The php.ini session settings the persistence falls back to when configuration does not name one. + * + * @internal + */ +final readonly class IniDefaults +{ + /** + * @param Closure(string): (string|false) $reader returns a php.ini value by name, or false when unset + */ + public function __construct( + private Closure $reader, + ) {} + + public static function fromPhpIni(): self + { + return new self(reader: ini_get(...)); + } + + /** + * @return array{ + * gc_maxlifetime: int, + * cache_limiter: string, + * cache_expire: int, + * name: string, + * cookie_lifetime: int, + * cookie_path: string, + * cookie_domain: string, + * cookie_secure: bool, + * cookie_httponly: bool, + * cookie_samesite: string, + * } + */ + public function settings(): array + { + return [ + 'gc_maxlifetime' => $this->int( + name : 'session.gc_maxlifetime', + default: 1440, + ), + 'cache_limiter' => $this->string( + name : 'session.cache_limiter', + default: 'nocache', + ), + 'cache_expire' => $this->int( + name : 'session.cache_expire', + default: 0, + ), + 'name' => $this->string( + name : 'session.name', + default: 'PHPSESSID', + ), + 'cookie_lifetime' => $this->int( + name : 'session.cookie_lifetime', + default: 0, + ), + 'cookie_path' => $this->string( + name : 'session.cookie_path', + default: '/', + ), + 'cookie_domain' => $this->string( + name : 'session.cookie_domain', + default: '', + ), + 'cookie_secure' => $this->bool(name: 'session.cookie_secure'), + 'cookie_httponly' => $this->bool(name: 'session.cookie_httponly'), + 'cookie_samesite' => $this->string( + name : 'session.cookie_samesite', + default: '', + ), + ]; + } + + private function bool(string $name): bool + { + return (bool) filter_var( + value : ($this->reader)($name), + filter : FILTER_VALIDATE_BOOLEAN, + options: FILTER_NULL_ON_FAILURE, + ); + } + + private function int(string $name, int $default): int + { + $value = (int) ($this->reader)($name); + + return 0 === $value ? $default : $value; + } + + private function string(string $name, string $default): string + { + $value = ($this->reader)($name); + + return false === $value || '' === $value ? $default : $value; + } +} diff --git a/src/Session/PhpDbSessionPersistence.php b/src/Session/PhpDbSessionPersistence.php new file mode 100644 index 0000000..0631e0d --- /dev/null +++ b/src/Session/PhpDbSessionPersistence.php @@ -0,0 +1,227 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use Mezzio\Session\InitializePersistenceIdInterface; +use Mezzio\Session\Persistence\CacheHeadersGeneratorTrait; +use Mezzio\Session\Persistence\SessionCookieAwareTrait; +use Mezzio\Session\Session; +use Mezzio\Session\SessionCookiePersistenceInterface; +use Mezzio\Session\SessionIdentifierAwareInterface; +use Mezzio\Session\SessionInterface; +use Mezzio\Session\SessionPersistenceInterface; +use Override; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\Sql\Insert; +use PhpDb\Sql\Exception\InvalidArgumentException as SqlInvalidArgumentException; +use PhpDb\Sql\Sql; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Random\RandomException; + +use function bin2hex; +use function date; +use function is_array; +use function random_bytes; +use function time; + +/** + * Async-safe PhpDb-backed session persistence. + * + * Does not use ext-session or any process-global state. Session identity is + * retrieved via SessionIdentifierAwareInterface::getId() (1.x; moves to + * SessionInterface in 2.0), making this implementation safe + * for use in concurrent async environments + * (TrueAsync, Swoole, ReactPHP). + * + * Payload is serialized using PHP's serialize()/unserialize(). Objects stored + * in session data must implement __serialize()/__unserialize() for correct + * round-tripping. + * + * @phpstan-type SessionSettings array{ + * gc_maxlifetime?: int, + * cache_limiter?: string, + * cache_expire?: int, + * name?: string, + * cookie_lifetime?: int, + * cookie_path?: string, + * cookie_domain?: string, + * cookie_secure?: bool, + * cookie_httponly?: bool, + * cookie_samesite?: string, + * } + */ +final class PhpDbSessionPersistence implements InitializePersistenceIdInterface, SessionPersistenceInterface +{ + use CacheHeadersGeneratorTrait; + use SessionCookieAwareTrait; + + private const string TIMESTAMP_FORMAT = 'Y-m-d H:i:s'; + + private readonly Sql $sql; + + private int $gcMaxLifetime; + + /** + * @param SessionSettings $sessionConfig overrides the php.ini session defaults, key by key + */ + public function __construct(AdapterInterface $adapter, array $sessionConfig = []) + { + $settings = $sessionConfig + IniDefaults::fromPhpIni()->settings(); + + $this->sql = new Sql( + adapter: $adapter, + table : SessionTable::Session->value, + ); + $this->gcMaxLifetime = $settings['gc_maxlifetime']; + $this->cacheLimiter = $settings['cache_limiter']; + $this->cacheExpire = $settings['cache_expire']; + $this->cookieName = $settings['name']; + $this->cookieLifetime = $settings['cookie_lifetime']; + $this->cookiePath = $settings['cookie_path']; + $this->cookieDomain = $settings['cookie_domain']; + $this->cookieSecure = $settings['cookie_secure']; + $this->cookieHttpOnly = $settings['cookie_httponly']; + $this->cookieSameSite = $settings['cookie_samesite']; + } + + /** + * @throws RandomException + */ + #[Override] + public function initializeId(SessionInterface $session): SessionInterface + { + $id = $this->idOf($session); + + if ('' !== $id && ! $session->isRegenerated()) { + return $session; + } + + /** @var array $data */ + $data = $session->toArray(); + + return new Session($data, $this->generateId()); + } + + /** + * @throws SqlInvalidArgumentException + */ + #[Override] + public function initializeSessionFromRequest(ServerRequestInterface $request): SessionInterface + { + $id = $this->getSessionCookieValueFromRequest($request); + + if ('' === $id) { + return new Session([], ''); + } + + $select = $this->sql->select() + ->columns(['payload']) + ->where(['id' => $id]); + $select->where->greaterThan('expires_at', date(format: self::TIMESTAMP_FORMAT)); + + $row = $this->sql->prepareStatementForSqlObject($select)->execute()?->current(); + + if (! is_array($row)) { + // Session not found or expired — return empty session keeping same ID. + // Browser already holds the cookie; on write it will upsert. + return new Session([], $id); + } + + return new Session(SessionPayload::decode(payload: (string) ($row['payload'] ?? '')), $id); + } + + /** + * @throws RandomException + * @throws SqlInvalidArgumentException + */ + #[Override] + public function persistSession(SessionInterface $session, ResponseInterface $response): ResponseInterface + { + // Retrieve the session ID — uses SessionIdentifierAwareInterface (1.x); + // getId() moves to SessionInterface in 2.0. + $id = $this->idOf($session); + + // Regenerate: either explicitly requested, or new session with data. + if ($session->isRegenerated() || ('' === $id && $session->hasChanged())) { + if ('' !== $id && $session->isRegenerated()) { + $this->destroy($id); + } + + $id = $this->generateId(); + } + + // No ID means a new session was created but never written to. + if ('' === $id) { + return $response; + } + + // Unchanged sessions do not need a new write or cookie. + if (! $session->hasChanged()) { + return $response; + } + + $ttl = + $session instanceof SessionCookiePersistenceInterface && 0 < $session->getSessionLifetime() + ? $session->getSessionLifetime() + : $this->gcMaxLifetime; + + $expiresAt = date( + format : self::TIMESTAMP_FORMAT, + timestamp: time() + $ttl, + ); + $now = date(format: self::TIMESTAMP_FORMAT); + $payload = SessionPayload::encode(data: $session->toArray()); + + $this->sql->prepareStatementForSqlObject( + new Insert(table: SessionTable::Session->value)->values([ + 'id' => $id, + 'payload' => $payload, + 'expires_at' => $expiresAt, + 'modified_at' => $now, + ]), + ) + ->execute(); + + $response = $this->addSessionCookieHeaderToResponse($response, $id, $session); + + return $this->addCacheHeadersToResponse($response); + } + + /** + * @throws SqlInvalidArgumentException + */ + private function destroy(string $id): void + { + $delete = $this->sql->delete()->where(['id' => $id]); + $this->sql->prepareStatementForSqlObject($delete)->execute(); + } + + /** + * @throws RandomException + */ + private function generateId(): string + { + return bin2hex(random_bytes(length: 16)); + } + + // getId() is on SessionIdentifierAwareInterface in 1.x and moves to SessionInterface in 2.0. + private function idOf(SessionInterface $session): string + { + return $session instanceof SessionIdentifierAwareInterface + ? $session->getId() + : ''; + } +} diff --git a/src/Session/SessionPayload.php b/src/Session/SessionPayload.php new file mode 100644 index 0000000..fb7748a --- /dev/null +++ b/src/Session/SessionPayload.php @@ -0,0 +1,50 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use function is_array; +use function serialize; +use function unserialize; + +/** + * Encodes session data for the payload column and decodes it back. + * + * Objects stored in a session must implement __serialize()/__unserialize() to round-trip. + * + * @internal + */ +final class SessionPayload +{ + /** + * A payload that does not decode to an array yields an empty session. + * + * @return array + */ + public static function decode(string $payload): array + { + $data = unserialize(data: $payload); + + /** @var array */ + return is_array($data) ? $data : []; + } + + /** + * @param array $data + */ + public static function encode(array $data): string + { + return serialize(value: $data); + } +} diff --git a/src/Session/SessionProvider.php b/src/Session/SessionProvider.php new file mode 100644 index 0000000..c83c124 --- /dev/null +++ b/src/Session/SessionProvider.php @@ -0,0 +1,66 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use Mezzio\Session\SessionPersistenceInterface; +use PhpDb\Session\Console\Container\InitDbCommandFactory; +use PhpDb\Session\Console\InitDbCommand; +use PhpDb\Session\Container\DbSessionHandlerFactory; +use PhpDb\Session\Container\PhpDbSessionPersistenceFactory; +use Webware\Console\ConsoleInterface; + +/** + * Wiring for the database-backed session persistence. + * + * Named `SessionProvider` rather than `ConfigProvider` so it never collides with the + * `PhpDb\Session\ConfigProvider` of php-db/phpdb-mezzio-session, whose namespace this package + * presents. + * + * @phpstan-type SessionDependencies array{ + * aliases: array, + * factories: array, + * } + */ +final class SessionProvider +{ + /** @return SessionDependencies */ + public function getDependencies(): array + { + return [ + 'aliases' => [ + // PhpDbSessionPersistence is the default: async-safe, no ext-session. + SessionPersistenceInterface::class => PhpDbSessionPersistence::class, + ], + 'factories' => [ + DbSessionHandler::class => DbSessionHandlerFactory::class, + PhpDbSessionPersistence::class => PhpDbSessionPersistenceFactory::class, + InitDbCommand::class => InitDbCommandFactory::class, + ], + ]; + } + + /** @return array */ + public function __invoke(): array + { + return [ + 'dependencies' => $this->getDependencies(), + ConsoleInterface::class => [ + 'commands' => [ + 'session:init-db' => InitDbCommand::class, + ], + ], + ]; + } +} diff --git a/src/Session/SessionTable.php b/src/Session/SessionTable.php new file mode 100644 index 0000000..9687d45 --- /dev/null +++ b/src/Session/SessionTable.php @@ -0,0 +1,25 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session; + +use PhpDb\SchemaInterface; + +/** + * The session table, named once so the handler, the persistence and the schema cannot drift apart. + */ +enum SessionTable: string implements SchemaInterface +{ + case Session = 'session'; +} diff --git a/src/Session/Sql/Insert.php b/src/Session/Sql/Insert.php new file mode 100644 index 0000000..9b51329 --- /dev/null +++ b/src/Session/Sql/Insert.php @@ -0,0 +1,42 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace PhpDb\Session\Sql; + +use PhpDb\Sql\Insert as BaseInsert; + +/** + * Session-specific INSERT with upsert clause. + * + * Overrides the INSERT specification to append ON DUPLICATE KEY UPDATE for the + * three mutable session columns. The parent processInsert() substitutes only + * %1$s (table), %2$s (columns), %3$s (values); everything after %3$s is emitted + * verbatim, so no additional process* method is required. + * + * @todo When PhpDb\Adapter\Platform\PlatformInterface gains buildUpsertClause() + * support, delegate to that instead of hardcoding MySQL syntax here so + * this class becomes portable across all PhpDb driver packages. + */ +final class Insert extends BaseInsert +{ + /** @var array[]|string[] */ + protected array $specifications = [ + self::SPECIFICATION_INSERT => 'INSERT INTO %1$s (%2$s) VALUES (%3$s)' + . ' ON DUPLICATE KEY UPDATE' + . ' payload = VALUES(payload)' + . ', expires_at = VALUES(expires_at)' + . ', modified_at = VALUES(modified_at)', + self::SPECIFICATION_SELECT => 'INSERT INTO %1$s %2$s %3$s', + ]; +} diff --git a/test/integration/Session/Console/InitDbCommandTest.php b/test/integration/Session/Console/InitDbCommandTest.php new file mode 100644 index 0000000..63a6c40 --- /dev/null +++ b/test/integration/Session/Console/InitDbCommandTest.php @@ -0,0 +1,119 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\PhpDb\Session\Console; + +use PDO; +use PhpDb\Session\Console\InitDbCommand; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use WebwareTestIntegration\PhpDb\Support\MysqlSessionTestCase; + +/** + * The expected columns are the ones of the `session` table in the IMS schema this table was taken + * from (data/schema/015_session.sql), less its database-side timestamp defaults. + */ +#[CoversClass(InitDbCommand::class)] +#[CoversMethod(InitDbCommand::class, 'execute')] +#[RequiresPhpExtension('pdo_mysql')] +#[Group('integration')] +#[Group('integration-mysql')] +final class InitDbCommandTest extends MysqlSessionTestCase +{ + #[Test] + public function createsTheSessionTableWithTheImsColumns(): void + { + $statement = $this->pdo->query( + query: 'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY FROM INFORMATION_SCHEMA.COLUMNS' + . " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'session' ORDER BY ORDINAL_POSITION", + ); + + self::assertNotFalse($statement); + self::assertSame( + [ + ['COLUMN_NAME' => 'id', 'COLUMN_TYPE' => 'varchar(64)', 'IS_NULLABLE' => 'NO', 'COLUMN_KEY' => 'PRI'], + ['COLUMN_NAME' => 'payload', 'COLUMN_TYPE' => 'mediumtext', 'IS_NULLABLE' => 'NO', 'COLUMN_KEY' => ''], + [ + 'COLUMN_NAME' => 'modified_at', + 'COLUMN_TYPE' => 'datetime', + 'IS_NULLABLE' => 'NO', + 'COLUMN_KEY' => '', + ], + [ + 'COLUMN_NAME' => 'expires_at', + 'COLUMN_TYPE' => 'datetime', + 'IS_NULLABLE' => 'NO', + 'COLUMN_KEY' => 'MUL', + ], + ], + $statement->fetchAll(PDO::FETCH_ASSOC), + ); + } + + #[Test] + public function indexesTheExpiryColumn(): void + { + $statement = $this->pdo->query( + query: 'SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS' + . " WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'session' AND INDEX_NAME <> 'PRIMARY'", + ); + + self::assertNotFalse($statement); + self::assertSame( + [['INDEX_NAME' => 'idx_expires_at', 'COLUMN_NAME' => 'expires_at']], + $statement->fetchAll(PDO::FETCH_ASSOC), + ); + } + + #[Test] + public function runningWithDropEmptiesTheTable(): void + { + $this->insertRow( + id : 'gone', + payload : 'data', + expiresAt: '2999-01-01 00:00:00', + ); + + new CommandTester(new InitDbCommand( + adapter: $this->adapter, + table : 'session', + ))->execute(['--drop' => true]); + + self::assertSame(0, $this->countRows()); + } + + #[Test] + public function runningWithoutDropKeepsExistingRows(): void + { + $this->insertRow( + id : 'kept', + payload : 'data', + expiresAt: '2999-01-01 00:00:00', + ); + + $tester = new CommandTester(new InitDbCommand( + adapter: $this->adapter, + table : 'session', + )); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertSame(1, $this->countRows()); + } +} diff --git a/test/integration/Session/DbSessionHandlerTest.php b/test/integration/Session/DbSessionHandlerTest.php new file mode 100644 index 0000000..78033de --- /dev/null +++ b/test/integration/Session/DbSessionHandlerTest.php @@ -0,0 +1,146 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\PhpDb\Session; + +use PhpDb\Session\DbSessionHandler; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\Test; +use WebwareTestIntegration\PhpDb\Support\MysqlSessionTestCase; + +use function date; +use function ini_get; +use function strtotime; +use function time; + +#[CoversClass(DbSessionHandler::class)] +#[CoversMethod(DbSessionHandler::class, 'open')] +#[CoversMethod(DbSessionHandler::class, 'close')] +#[CoversMethod(DbSessionHandler::class, 'read')] +#[CoversMethod(DbSessionHandler::class, 'write')] +#[CoversMethod(DbSessionHandler::class, 'destroy')] +#[CoversMethod(DbSessionHandler::class, 'gc')] +#[RequiresPhpExtension('pdo_mysql')] +#[Group('integration')] +#[Group('integration-mysql')] +final class DbSessionHandlerTest extends MysqlSessionTestCase +{ + #[Test] + public function destroyRemovesTheSession(): void + { + $handler = new DbSessionHandler($this->adapter); + $handler->write('abc', 'data'); + $handler->write('keep', 'data'); + + self::assertTrue($handler->destroy('abc')); + self::assertFalse($handler->read('abc')); + self::assertSame('data', $handler->read('keep')); + } + + #[Test] + public function gcRemovesOnlyExpiredSessionsAndReportsHowMany(): void + { + $past = date( + format : 'Y-m-d H:i:s', + timestamp: time() - 60, + ); + $this->insertRow( + id : 'stale-1', + payload : 'old', + expiresAt: $past, + ); + $this->insertRow( + id : 'stale-2', + payload : 'old', + expiresAt: $past, + ); + + $handler = new DbSessionHandler($this->adapter); + $handler->write('live', 'data'); + + self::assertSame(2, $handler->gc(1440)); + self::assertSame(1, $this->countRows()); + self::assertSame('data', $handler->read('live')); + } + + #[Test] + public function openAndCloseAlwaysSucceed(): void + { + $handler = new DbSessionHandler($this->adapter); + + self::assertTrue($handler->open('', 'PHPSESSID')); + self::assertTrue($handler->close()); + } + + #[Test] + public function readReturnsFalseForAnExpiredSession(): void + { + $this->insertRow( + id : 'stale', + payload : 'old', + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() - 60, + ), + ); + + self::assertFalse(new DbSessionHandler($this->adapter)->read('stale')); + } + + #[Test] + public function readReturnsFalseForAnUnknownIdentifier(): void + { + self::assertFalse(new DbSessionHandler($this->adapter)->read('missing')); + } + + #[Test] + public function readReturnsWhatWasWritten(): void + { + $handler = new DbSessionHandler($this->adapter); + + self::assertTrue($handler->write('abc', 'user|s:4:"joey";')); + self::assertSame('user|s:4:"joey";', $handler->read('abc')); + } + + #[Test] + public function writeExpiresTheSessionAfterTheConfiguredLifetime(): void + { + $lifetime = (int) ini_get(option: 'session.gc_maxlifetime'); + + $before = time(); + new DbSessionHandler($this->adapter)->write('abc', 'data'); + $after = time(); + + $expiresAt = (int) strtotime((string) $this->expiresAtOf('abc')); + + self::assertGreaterThan(0, $lifetime); + self::assertGreaterThanOrEqual($before + $lifetime, $expiresAt); + self::assertLessThanOrEqual($after + $lifetime, $expiresAt); + } + + #[Test] + public function writingTheSameIdentifierTwiceReplacesThePayload(): void + { + $handler = new DbSessionHandler($this->adapter); + + $handler->write('abc', 'first'); + $handler->write('abc', 'second'); + + self::assertSame('second', $handler->read('abc')); + self::assertSame(1, $this->countRows()); + } +} diff --git a/test/integration/Session/PhpDbSessionPersistenceTest.php b/test/integration/Session/PhpDbSessionPersistenceTest.php new file mode 100644 index 0000000..2a244ce --- /dev/null +++ b/test/integration/Session/PhpDbSessionPersistenceTest.php @@ -0,0 +1,289 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\PhpDb\Session; + +use Laminas\Diactoros\Response; +use Laminas\Diactoros\ServerRequest; +use Laminas\ServiceManager\ServiceManager; +use Mezzio\Session\Session; +use Mezzio\Session\SessionInterface; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Session\Container\PhpDbSessionPersistenceFactory; +use PhpDb\Session\PhpDbSessionPersistence; +use PhpDb\Session\SessionPayload; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\Test; +use Psr\Http\Message\ServerRequestInterface; +use WebwareTestIntegration\PhpDb\Support\MysqlSessionTestCase; + +use function date; +use function serialize; +use function strtotime; +use function substr; +use function time; + +#[CoversClass(PhpDbSessionPersistence::class)] +#[CoversClass(PhpDbSessionPersistenceFactory::class)] +#[CoversMethod(PhpDbSessionPersistenceFactory::class, '__invoke')] +#[CoversMethod(PhpDbSessionPersistence::class, '__construct')] +#[CoversMethod(PhpDbSessionPersistence::class, 'initializeSessionFromRequest')] +#[CoversMethod(PhpDbSessionPersistence::class, 'persistSession')] +#[RequiresPhpExtension('pdo_mysql')] +#[Group('integration')] +#[Group('integration-mysql')] +final class PhpDbSessionPersistenceTest extends MysqlSessionTestCase +{ + #[Test] + public function aNewSessionThatWasNeverWrittenToIsIgnored(): void + { + $response = $this->persistence()->persistSession(new Session([]), new Response()); + + self::assertSame(0, $this->countRows()); + self::assertFalse($response->hasHeader('Set-Cookie')); + } + + #[Test] + public function aNewSessionWithDataIsStoredAndItsCookieIsSet(): void + { + $persistence = $this->persistence(); + $session = $persistence->initializeSessionFromRequest(new ServerRequest()); + $session->set('user', 'joey'); + + $response = $persistence->persistSession($session, new Response()); + + $cookie = $response->getHeaderLine('Set-Cookie'); + self::assertMatchesRegularExpression('/^PHPSESSID=([0-9a-f]{32});/', $cookie); + + $id = substr( + string: $cookie, + offset: 10, + length: 32, + ); + self::assertSame(['user' => 'joey'], SessionPayload::decode(payload: (string) $this->payloadOf($id))); + self::assertSame(1, $this->countRows()); + } + + #[Test] + public function anExpiredSessionIsRestoredEmptyUnderTheSameIdentifier(): void + { + $this->insertRow( + id : 'stale-id', + payload : serialize(value: ['user' => 'joey']), + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() - 60, + ), + ); + + $session = $this->persistence()->initializeSessionFromRequest($this->requestWithCookie(value: 'stale-id')); + + self::assertSame([], $session->toArray()); + self::assertSame('stale-id', $this->idOf($session)); + } + + #[Test] + public function aPayloadThatIsNotAnArrayIsRestoredEmpty(): void + { + $this->insertRow( + id : 'odd-id', + payload : serialize(value: 'not an array'), + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() + 600, + ), + ); + + $session = $this->persistence()->initializeSessionFromRequest($this->requestWithCookie(value: 'odd-id')); + + self::assertSame([], $session->toArray()); + } + + #[Test] + public function aRegeneratedSessionMovesToANewIdentifierAndDropsTheOldRow(): void + { + $persistence = $this->persistence(); + $this->insertRow( + id : 'old-id', + payload : serialize(value: ['user' => 'joey']), + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() + 600, + ), + ); + + $session = $persistence->initializeSessionFromRequest($this->requestWithCookie(value: 'old-id')); + $response = $persistence->persistSession($session->regenerate(), new Response()); + + self::assertNull($this->payloadOf('old-id')); + self::assertSame(1, $this->countRows()); + self::assertDoesNotMatchRegularExpression('/PHPSESSID=old-id/', $response->getHeaderLine('Set-Cookie')); + } + + #[Test] + public function aRequestWithoutACookieStartsAnEmptySession(): void + { + $session = $this->persistence()->initializeSessionFromRequest(new ServerRequest()); + + self::assertSame([], $session->toArray()); + self::assertSame('', $this->idOf($session)); + } + + #[Test] + public function aSessionThatAsksForALifetimeIsKeptForThatLong(): void + { + $persistence = $this->persistence(config: ['gc_maxlifetime' => 900]); + $session = $persistence->initializeSessionFromRequest(new ServerRequest()); + $session->set('user', 'joey'); + $session->persistSessionFor(300); + + $before = time(); + $persistence->persistSession($session, new Response()); + $after = time(); + + $expiresAt = (int) strtotime((string) $this->onlyExpiry()); + + self::assertGreaterThanOrEqual($before + 300, $expiresAt); + self::assertLessThanOrEqual($after + 300, $expiresAt); + } + + #[Test] + public function aSessionThatDidNotChangeIsNotWritten(): void + { + $session = new Session(['user' => 'joey'], 'known-id'); + + $response = $this->persistence()->persistSession($session, new Response()); + + self::assertSame(0, $this->countRows()); + self::assertFalse($response->hasHeader('Set-Cookie')); + } + + #[Test] + public function aStoredSessionIsRestoredFromItsCookie(): void + { + $this->insertRow( + id : 'known-id', + payload : serialize(value: ['user' => 'joey']), + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() + 600, + ), + ); + + $session = $this->persistence()->initializeSessionFromRequest($this->requestWithCookie(value: 'known-id')); + + self::assertSame(['user' => 'joey'], $session->toArray()); + self::assertSame('known-id', $this->idOf($session)); + } + + #[Test] + public function changingAnExistingSessionUpdatesItsRow(): void + { + $persistence = $this->persistence(); + $this->insertRow( + id : 'known-id', + payload : serialize(value: ['user' => 'joey']), + expiresAt: date( + format : 'Y-m-d H:i:s', + timestamp: time() + 600, + ), + ); + + $session = $persistence->initializeSessionFromRequest($this->requestWithCookie(value: 'known-id')); + $session->set('theme', 'dark'); + $persistence->persistSession($session, new Response()); + + self::assertSame(1, $this->countRows()); + self::assertSame( + ['user' => 'joey', 'theme' => 'dark'], + SessionPayload::decode(payload: (string) $this->payloadOf('known-id')), + ); + } + + #[Test] + public function theConfiguredCookieNameIsUsedToReadAndWriteTheCookie(): void + { + $persistence = $this->persistence(config: ['name' => 'WEBWARE']); + $session = $persistence->initializeSessionFromRequest(new ServerRequest()); + $session->set('user', 'joey'); + + $response = $persistence->persistSession($session, new Response()); + + self::assertMatchesRegularExpression('/^WEBWARE=[0-9a-f]{32};/', $response->getHeaderLine('Set-Cookie')); + + $restored = $persistence->initializeSessionFromRequest( + $this->requestWithCookie( + value: substr( + string: $response->getHeaderLine('Set-Cookie'), + offset: 8, + length: 32, + ), + name : 'WEBWARE', + ), + ); + + self::assertSame(['user' => 'joey'], $restored->toArray()); + } + + #[Test] + public function theSessionIsKeptForTheConfiguredLifetime(): void + { + $persistence = $this->persistence(config: ['gc_maxlifetime' => 900]); + $session = $persistence->initializeSessionFromRequest(new ServerRequest()); + $session->set('user', 'joey'); + + $before = time(); + $persistence->persistSession($session, new Response()); + $after = time(); + + $expiresAt = (int) strtotime((string) $this->onlyExpiry()); + + self::assertGreaterThanOrEqual($before + 900, $expiresAt); + self::assertLessThanOrEqual($after + 900, $expiresAt); + } + + private function idOf(SessionInterface $session): string + { + self::assertInstanceOf(Session::class, $session); + + return $session->getId(); + } + + private function onlyExpiry(): ?string + { + $id = $this->pdo->query(query: 'SELECT id FROM `session`')?->fetchColumn(); + + return $this->expiresAtOf((string) $id); + } + + /** + * @param array{gc_maxlifetime?: int, name?: string} $config + */ + private function persistence(array $config = []): PhpDbSessionPersistence + { + $container = new ServiceManager(); + $container->setService('config', ['session' => $config]); + $container->setService(AdapterInterface::class, $this->adapter); + + return (new PhpDbSessionPersistenceFactory())($container); + } + + private function requestWithCookie(string $value, string $name = 'PHPSESSID'): ServerRequestInterface + { + return new ServerRequest()->withCookieParams([$name => $value]); + } +} diff --git a/test/integration/Support/MysqlSessionTestCase.php b/test/integration/Support/MysqlSessionTestCase.php new file mode 100644 index 0000000..3852f49 --- /dev/null +++ b/test/integration/Support/MysqlSessionTestCase.php @@ -0,0 +1,115 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\PhpDb\Support; + +use Override; +use PDO; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Mysql; +use PhpDb\Session\Console\InitDbCommand; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; + +use function getenv; +use function sprintf; + +/** + * A MySQL connection with a freshly created, empty session table, built by the command under test. + */ +class MysqlSessionTestCase extends TestCase +{ + protected AdapterInterface $adapter; + + protected PDO $pdo; + + protected function countRows(): int + { + return (int) $this->pdo->query(query: 'SELECT COUNT(*) FROM `session`')?->fetchColumn(); + } + + protected function expiresAtOf(string $id): ?string + { + $statement = $this->pdo->prepare(query: 'SELECT expires_at FROM `session` WHERE id = ?'); + $statement->execute([$id]); + + $expiresAt = $statement->fetchColumn(); + + return false === $expiresAt ? null : (string) $expiresAt; + } + + protected function insertRow(string $id, string $payload, string $expiresAt): void + { + $this->pdo->prepare( + query: 'INSERT INTO `session` (id, payload, modified_at, expires_at) VALUES (?, ?, NOW(), ?)', + ) + ->execute([$id, $payload, $expiresAt]); + } + + protected function payloadOf(string $id): ?string + { + $statement = $this->pdo->prepare(query: 'SELECT payload FROM `session` WHERE id = ?'); + $statement->execute([$id]); + + $payload = $statement->fetchColumn(); + + return false === $payload ? null : (string) $payload; + } + + #[Override] + protected function setUp(): void + { + if (! getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL')) { + self::markTestSkipped('MySQL adapter is not configured.'); + } + + $hostname = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME'); + $port = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL_PORT'); + $username = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL_USERNAME'); + $password = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD'); + $database = (string) getenv(name: 'TESTS_PHPDB_ADAPTER_MYSQL_DATABASE'); + + $this->adapter = AdapterFactory::create( + adapterConfig : [ + 'driver' => Mysql\Pdo\Driver::class, + 'connection' => [ + 'hostname' => $hostname, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'database' => $database, + ], + ], + driverDependencies: new Mysql\ConfigProvider()->getDependencies(), + ); + + $this->pdo = new PDO( + dsn : sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $hostname, $port, $database), + username: $username, + password: $password, + options : [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + + new CommandTester(new InitDbCommand( + adapter: $this->adapter, + table : 'session', + ))->execute(['--drop' => true]); + } + + #[Override] + protected function tearDown(): void + { + $this->pdo->exec(statement: 'DROP TABLE IF EXISTS `session`'); + } +} diff --git a/test/integration/WebwareProviderWiringTest.php b/test/integration/WebwareProviderWiringTest.php index a63b79d..c3c7984 100644 --- a/test/integration/WebwareProviderWiringTest.php +++ b/test/integration/WebwareProviderWiringTest.php @@ -15,6 +15,7 @@ namespace WebwareTestIntegration\PhpDb; use JsonException; +use PhpDb\Session\SessionProvider; use PhpDb\WebwareProvider; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -42,11 +43,11 @@ public function composerDeclaresThisPackageAsItsConfigProvider(): void { $json = (string) file_get_contents(__DIR__ . '/../../composer.json'); - /** @var array{extra: array{laminas: array{'config-provider': string}}} $composer */ + /** @var array{extra: array{laminas: array{'config-provider': list}}} $composer */ $composer = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); self::assertSame( - WebwareProvider::class, + [WebwareProvider::class, SessionProvider::class], $composer['extra']['laminas']['config-provider'], ); } diff --git a/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php b/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php new file mode 100644 index 0000000..fdd988f --- /dev/null +++ b/test/unit/Session/Console/Container/InitDbCommandFactoryTest.php @@ -0,0 +1,43 @@ +ddlAdapter($executed); + $container = $this->createStub(ContainerInterface::class); + $container->method('get') + ->willReturnCallback( + static fn(string $id): AdapterInterface => $adapter, + ); + + $command = (new InitDbCommandFactory())($container); + + self::assertInstanceOf(InitDbCommand::class, $command); + + new CommandTester($command)->execute([]); + + self::assertStringContainsString('`session`', $executed[0]); + } +} diff --git a/test/unit/Session/Console/Ddl/Column/MediumTextTest.php b/test/unit/Session/Console/Ddl/Column/MediumTextTest.php new file mode 100644 index 0000000..82b3114 --- /dev/null +++ b/test/unit/Session/Console/Ddl/Column/MediumTextTest.php @@ -0,0 +1,41 @@ +addColumn(new MediumText( + name : 'payload', + nullable: false, + )); + + $driver = $this->createStub(DriverInterface::class); + $connection = $this->createStub(ConnectionInterface::class); + $driver->method('getConnection')->willReturn($connection); + + $platform = new Platform(); + $platform->setSubject($table); + + self::assertStringContainsString( + '`payload` MEDIUMTEXT NOT NULL', + $platform->getSqlString(new AdapterPlatform($driver)), + ); + } +} diff --git a/test/unit/Session/Console/InitDbCommandTest.php b/test/unit/Session/Console/InitDbCommandTest.php new file mode 100644 index 0000000..05e45bb --- /dev/null +++ b/test/unit/Session/Console/InitDbCommandTest.php @@ -0,0 +1,84 @@ +ddlAdapter($executed), + table : 'session', + ); + + self::assertSame('session:init-db', $command->getName()); + self::assertTrue($command->getDefinition()->hasOption('drop')); + } + + #[Test] + public function executeCreatesTheSessionTable(): void + { + $executed = []; + $tester = new CommandTester(new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'session', + )); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertCount(1, $executed); + self::assertStringContainsString('CREATE TABLE IF NOT EXISTS `session`', $executed[0]); + self::assertStringContainsString('Creating table session...', $tester->getDisplay()); + self::assertStringContainsString('Session table initialized.', $tester->getDisplay()); + self::assertStringNotContainsString('Dropping table', $tester->getDisplay()); + } + + #[Test] + public function executeDropsTheTableFirstWhenRequested(): void + { + $executed = []; + $tester = new CommandTester(new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'session', + )); + $tester->execute(['--drop' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertCount(2, $executed); + self::assertStringContainsString('DROP TABLE IF EXISTS `session`', $executed[0]); + self::assertStringContainsString('CREATE TABLE IF NOT EXISTS `session`', $executed[1]); + self::assertStringContainsString('Dropping table session...', $tester->getDisplay()); + } + + #[Test] + public function executeUsesTheConfiguredTableName(): void + { + $executed = []; + + new CommandTester(new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'app_session', + ))->execute([]); + + self::assertCount(1, $executed); + self::assertStringContainsString('`app_session`', $executed[0]); + } +} diff --git a/test/unit/Session/Console/Schema/SessionSchemaTest.php b/test/unit/Session/Console/Schema/SessionSchemaTest.php new file mode 100644 index 0000000..b394c8b --- /dev/null +++ b/test/unit/Session/Console/Schema/SessionSchemaTest.php @@ -0,0 +1,135 @@ +renderSql(new SessionSchema()->dropTable('session')); + + self::assertStringContainsString('DROP TABLE IF EXISTS `session`', $sql); + } + + #[Test] + public function sessionTableCarriesThePrimaryKeyAndTheExpiryIndex(): void + { + $constraints = $this->constraints(new SessionSchema()->sessionTable('session')); + + self::assertCount(2, $constraints); + self::assertInstanceOf(PrimaryKey::class, $constraints[0]); + self::assertInstanceOf(Index::class, $constraints[1]); + self::assertSame('idx_expires_at', $constraints[1]->getName()); + } + + #[Test] + public function sessionTableHasExactlyTheColumnsBothClassesWrite(): void + { + $names = array_map( + static fn(ColumnInterface $column): string => $column->getName(), + $this->columns(new SessionSchema()->sessionTable('session')), + ); + + self::assertSame(['id', 'payload', 'modified_at', 'expires_at'], $names); + } + + #[Test] + public function sessionTableOptionsSetEngineCharsetAndCollation(): void + { + $options = new SessionSchema()->sessionTable('session') + ->getOptions(); + + self::assertSame(['engine', 'default charset', 'collate'], array_keys($options)); + self::assertInstanceOf(Literal::class, $options['engine']); + self::assertSame('InnoDB', $options['engine']->getLiteral()); + self::assertSame('utf8mb4', $options['default charset']->getLiteral()); + self::assertSame('utf8mb4_unicode_ci', $options['collate']->getLiteral()); + } + + #[Test] + public function sessionTableRendersTheExactStatement(): void + { + $sql = (string) preg_replace( + pattern : '/\s+/', + replacement: ' ', + subject : $this->renderSql(new SessionSchema()->sessionTable('session')), + ); + + self::assertSame( + 'CREATE TABLE IF NOT EXISTS `session` ( ' + . '`id` VARCHAR(64) NOT NULL, ' + . '`payload` MEDIUMTEXT NOT NULL, ' + . '`modified_at` DATETIME NOT NULL, ' + . '`expires_at` DATETIME NOT NULL , ' + . 'PRIMARY KEY (`id`), ' + . 'INDEX `idx_expires_at`(`expires_at`) ' + . ') ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci', + $sql, + ); + } + + #[Test] + public function sessionTableUsesTheTableNameItIsGiven(): void + { + $sql = $this->renderSql(new SessionSchema()->sessionTable('app_session')); + + self::assertStringContainsString('`app_session`', $sql); + } + + /** + * @return list + */ + private function columns(CreateTable $table): array + { + /** @var list */ + return $table->getRawState('columns'); + } + + /** + * @return list + */ + private function constraints(CreateTable $table): array + { + /** @var list */ + return $table->getRawState('constraints'); + } + + private function renderSql(CreateTable|DropTable $sql): string + { + $driver = $this->createStub(DriverInterface::class); + $connection = $this->createStub(ConnectionInterface::class); + $driver->method('getConnection')->willReturn($connection); + + $platform = new Platform(); + $platform->setSubject($sql); + + return $platform->getSqlString(new AdapterPlatform($driver)); + } +} diff --git a/test/unit/Session/Container/SessionFactoriesTest.php b/test/unit/Session/Container/SessionFactoriesTest.php new file mode 100644 index 0000000..e1bb957 --- /dev/null +++ b/test/unit/Session/Container/SessionFactoriesTest.php @@ -0,0 +1,74 @@ +container(services: [])), + ); + } + + #[Test] + public function persistenceFactoryBuildsThePersistenceFromTheConfigService(): void + { + self::assertInstanceOf( + PhpDbSessionPersistence::class, + (new PhpDbSessionPersistenceFactory())($this->container(services: [ + 'config' => ['session' => ['name' => 'WEBWARE']], + ])), + ); + } + + #[Test] + public function persistenceFactoryBuildsThePersistenceWithoutAConfigService(): void + { + self::assertInstanceOf( + PhpDbSessionPersistence::class, + (new PhpDbSessionPersistenceFactory())($this->container(services: [])), + ); + } + + /** + * @param array $services + */ + private function container(array $services): ContainerInterface + { + $services[AdapterInterface::class] = $this->createStub(AdapterInterface::class); + + $container = $this->createStub(ContainerInterface::class); + $container->method('has') + ->willReturnCallback( + static fn(string $id): bool => array_key_exists($id, $services), + ); + $container->method('get') + ->willReturnCallback( + static fn(string $id): mixed => $services[$id], + ); + + return $container; + } +} diff --git a/test/unit/Session/IniDefaultsTest.php b/test/unit/Session/IniDefaultsTest.php new file mode 100644 index 0000000..dd14ed8 --- /dev/null +++ b/test/unit/Session/IniDefaultsTest.php @@ -0,0 +1,111 @@ +settings(); + + self::assertGreaterThan(0, $settings['gc_maxlifetime']); + self::assertNotSame('', $settings['name']); + } + + #[Test] + public function settingsFallBackWhenIniValuesAreEmpty(): void + { + $defaults = new IniDefaults(reader: $this->reader(values: [ + 'session.gc_maxlifetime' => '0', + 'session.cache_limiter' => '', + 'session.cache_expire' => '0', + 'session.name' => '', + 'session.cookie_lifetime' => '0', + 'session.cookie_path' => '', + 'session.cookie_domain' => '', + 'session.cookie_secure' => '0', + 'session.cookie_httponly' => '0', + 'session.cookie_samesite' => '', + ])); + + self::assertSame( + [ + 'gc_maxlifetime' => 1440, + 'cache_limiter' => 'nocache', + 'cache_expire' => 0, + 'name' => 'PHPSESSID', + 'cookie_lifetime' => 0, + 'cookie_path' => '/', + 'cookie_domain' => '', + 'cookie_secure' => false, + 'cookie_httponly' => false, + 'cookie_samesite' => '', + ], + $defaults->settings(), + ); + } + + #[Test] + public function settingsFallBackWhenIniValuesAreUnset(): void + { + $settings = new IniDefaults(reader: $this->reader(values: []))->settings(); + + self::assertSame(1440, $settings['gc_maxlifetime']); + self::assertSame('PHPSESSID', $settings['name']); + self::assertFalse($settings['cookie_secure']); + } + + #[Test] + public function settingsReadEachIniValue(): void + { + $defaults = new IniDefaults(reader: $this->reader(values: [ + 'session.gc_maxlifetime' => '600', + 'session.cache_limiter' => 'private', + 'session.cache_expire' => '30', + 'session.name' => 'WEBWARE', + 'session.cookie_lifetime' => '120', + 'session.cookie_path' => '/app', + 'session.cookie_domain' => 'example.test', + 'session.cookie_secure' => '1', + 'session.cookie_httponly' => '1', + 'session.cookie_samesite' => 'Strict', + ])); + + self::assertSame( + [ + 'gc_maxlifetime' => 600, + 'cache_limiter' => 'private', + 'cache_expire' => 30, + 'name' => 'WEBWARE', + 'cookie_lifetime' => 120, + 'cookie_path' => '/app', + 'cookie_domain' => 'example.test', + 'cookie_secure' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict', + ], + $defaults->settings(), + ); + } + + /** + * @param array $values + */ + private function reader(array $values): Closure + { + return static fn(string $name): string|false => $values[$name] ?? false; + } +} diff --git a/test/unit/Session/PhpDbSessionPersistenceTest.php b/test/unit/Session/PhpDbSessionPersistenceTest.php new file mode 100644 index 0000000..1e334b6 --- /dev/null +++ b/test/unit/Session/PhpDbSessionPersistenceTest.php @@ -0,0 +1,59 @@ +persistence()->initializeId(new Session(['user' => 'joey'])); + + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $this->idOf($session)); + self::assertSame(['user' => 'joey'], $session->toArray()); + } + + #[Test] + public function initializeIdKeepsAnExistingIdentifier(): void + { + $session = new Session(['user' => 'joey'], 'existing-id'); + + self::assertSame($session, $this->persistence()->initializeId($session)); + } + + #[Test] + public function initializeIdReplacesTheIdentifierOfARegeneratedSession(): void + { + $original = new Session(['user' => 'joey'], 'old-id'); + $session = $this->persistence()->initializeId($original->regenerate()); + + self::assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $this->idOf($session)); + self::assertNotSame('old-id', $this->idOf($session)); + self::assertSame(['user' => 'joey'], $session->toArray()); + } + + private function idOf(SessionInterface $session): string + { + self::assertInstanceOf(Session::class, $session); + + return $session->getId(); + } + + private function persistence(): PhpDbSessionPersistence + { + return new PhpDbSessionPersistence(adapter: $this->createStub(AdapterInterface::class)); + } +} diff --git a/test/unit/Session/SessionPayloadTest.php b/test/unit/Session/SessionPayloadTest.php new file mode 100644 index 0000000..dfdda1b --- /dev/null +++ b/test/unit/Session/SessionPayloadTest.php @@ -0,0 +1,43 @@ + new ArrayObject([1, 2])])); + + self::assertInstanceOf(ArrayObject::class, $decoded['bag']); + self::assertSame([1, 2], $decoded['bag']->getArrayCopy()); + } + + #[Test] + public function decodeReturnsAnEmptySessionWhenThePayloadIsNotAnArray(): void + { + self::assertSame([], SessionPayload::decode(payload: serialize(value: 'not an array'))); + } + + #[Test] + public function decodeReturnsWhatEncodeStored(): void + { + $data = ['user' => 'joey', 'roles' => ['admin', 'member']]; + + self::assertSame($data, SessionPayload::decode(payload: SessionPayload::encode(data: $data))); + } +} diff --git a/test/unit/Session/SessionProviderTest.php b/test/unit/Session/SessionProviderTest.php new file mode 100644 index 0000000..4d999db --- /dev/null +++ b/test/unit/Session/SessionProviderTest.php @@ -0,0 +1,66 @@ + PhpDbSessionPersistence::class], + new SessionProvider()->getDependencies()['aliases'], + ); + } + + #[Test] + public function dependenciesRegisterEveryFactory(): void + { + self::assertSame( + [ + DbSessionHandler::class => DbSessionHandlerFactory::class, + PhpDbSessionPersistence::class => PhpDbSessionPersistenceFactory::class, + InitDbCommand::class => InitDbCommandFactory::class, + ], + new SessionProvider()->getDependencies()['factories'], + ); + } + + #[Test] + public function invokeCarriesTheDependencies(): void + { + $provider = new SessionProvider(); + + self::assertSame($provider->getDependencies(), $provider()['dependencies']); + } + + #[Test] + public function invokeRegistersTheInitDbCommandForConsoleDiscovery(): void + { + $config = (new SessionProvider())(); + + self::assertSame( + ['commands' => ['session:init-db' => InitDbCommand::class]], + $config[ConsoleInterface::class], + ); + } +} diff --git a/test/unit/Session/Sql/InsertTest.php b/test/unit/Session/Sql/InsertTest.php new file mode 100644 index 0000000..8811e43 --- /dev/null +++ b/test/unit/Session/Sql/InsertTest.php @@ -0,0 +1,46 @@ +createStub(DriverInterface::class); + $connection = $this->createStub(ConnectionInterface::class); + $driver->method('getConnection')->willReturn($connection); + + $insert = new Insert(table: 'session')->values([ + 'id' => 'abc', + 'payload' => 'data', + 'expires_at' => '2026-01-01 00:00:00', + 'modified_at' => '2026-01-01 00:00:00', + ]); + + $sql = (string) preg_replace( + pattern : '/\s+/', + replacement: ' ', + subject : $insert->getSqlString(new AdapterPlatform($driver)), + ); + + self::assertStringStartsWith('INSERT INTO `session` (', $sql); + self::assertStringEndsWith( + ' ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires_at = VALUES(expires_at), modified_at = VALUES(modified_at)', + $sql, + ); + } +} diff --git a/test/unit/Support/DdlAdapterTrait.php b/test/unit/Support/DdlAdapterTrait.php new file mode 100644 index 0000000..86f4b1b --- /dev/null +++ b/test/unit/Support/DdlAdapterTrait.php @@ -0,0 +1,42 @@ + $executed filled with each statement the adapter is asked to run + */ + private function ddlAdapter(array &$executed): AdapterInterface + { + $driver = $this->createStub(DriverInterface::class); + $connection = $this->createStub(ConnectionInterface::class); + $driver->method('getConnection')->willReturn($connection); + + $adapter = $this->createStub(AdapterInterface::class); + $adapter->method('getDriver')->willReturn($driver); + $adapter->method('getPlatform')->willReturn(new AdapterPlatform($driver)); + $adapter->method('query') + ->willReturnCallback( + function (string $sql) use (&$executed): ResultInterface { + $executed[] = $sql; + + return $this->createStub(ResultInterface::class); + }, + ); + + return $adapter; + } +}