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; + } +}