From 7eec35c5b67b4ad8a76fb70d1f589b865f64492a Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 30 Sep 2026 16:57:20 -0500 Subject: [PATCH 1/2] feat: add log:init-db command to create the log table The database log handler inserts into a table nothing created, so a failed write surfaced as a 500 in host apps. Add a log:init-db console command, registered under ConsoleInterface::class for webware-console discovery, that creates the table the handler writes to. The table name is read the way PhpDbHandlerFactory reads it, so the command and handler always agree. The DDL is verified against the existing fixture schema on a real MySQL, and --drop recreates the table. webware-console is a dev dependency (used to analyse and test the command) and a suggestion for hosts that run it. Signed-off-by: Joey Smith --- analysis-baseline.toml | 4 +- composer.json | 4 +- composer.lock | 462 +++++++++++++++++- src/ConfigProvider.php | 13 +- .../Container/InitDbCommandFactory.php | 47 ++ src/Console/Ddl/Column/LongText.php | 27 + src/Console/InitDbCommand.php | 97 ++++ src/Console/Schema/LogSchema.php | 110 +++++ .../integration/Console/InitDbCommandTest.php | 188 +++++++ test/unit/ConfigProviderTest.php | 28 ++ .../Container/InitDbCommandFactoryTest.php | 73 +++ test/unit/Console/Ddl/Column/LongTextTest.php | 41 ++ test/unit/Console/InitDbCommandTest.php | 91 ++++ test/unit/Console/Schema/LogSchemaTest.php | 162 ++++++ test/unit/Support/DdlAdapterTrait.php | 42 ++ 15 files changed, 1382 insertions(+), 7 deletions(-) create mode 100644 src/Console/Container/InitDbCommandFactory.php create mode 100644 src/Console/Ddl/Column/LongText.php create mode 100644 src/Console/InitDbCommand.php create mode 100644 src/Console/Schema/LogSchema.php create mode 100644 test/integration/Console/InitDbCommandTest.php create mode 100644 test/unit/Console/Container/InitDbCommandFactoryTest.php create mode 100644 test/unit/Console/Ddl/Column/LongTextTest.php create mode 100644 test/unit/Console/InitDbCommandTest.php create mode 100644 test/unit/Console/Schema/LogSchemaTest.php create mode 100644 test/unit/Support/DdlAdapterTrait.php diff --git a/analysis-baseline.toml b/analysis-baseline.toml index 4cde692..e8500e9 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -9,13 +9,13 @@ count = 1 [[issues]] file = "src/ConfigProvider.php" code = "invalid-return-statement" -message = '''Invalid return type for function `Webware\Log\ConfigProvider::__invoke`: expected `array{'dependencies': array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}, 'listener_providers': array, 'listeners': array>, 'templates': array{'paths': array{'log': list}}, Psr\Log\LoggerInterface::class: array{'auth_attribute': string, 'channel': string, 'log_errors': bool, 'process_translation': bool, 'process_uuid': bool, 'table': string}}`, but found `array{'Psr\Log\LoggerInterface': array{'auth_attribute': string, 'channel': string, 'log_errors': bool, 'process_translation': bool, 'process_uuid': bool, 'table': string}, 'dependencies': array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}, 'listener_providers': array{}, 'listeners': array>, 'templates': array{'paths': array{'log': list}}}`.''' +message = '''Invalid return type for function `Webware\Log\ConfigProvider::__invoke`: expected `array{'dependencies': array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Console\InitDbCommand::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}, 'listener_providers': array, 'listeners': array>, 'templates': array{'paths': array{'log': list}}, Psr\Log\LoggerInterface::class: array{'auth_attribute': string, 'channel': string, 'log_errors': bool, 'process_translation': bool, 'process_uuid': bool, 'table': string}, Webware\Console\ConsoleInterface::class: array{'commands': array}}`, but found `array{'Psr\Log\LoggerInterface': array{'auth_attribute': string, 'channel': string, 'log_errors': bool, 'process_translation': bool, 'process_uuid': bool, 'table': string}, 'Webware\Console\ConsoleInterface': array{'commands': array{'log:init-db': class-string('Webware\Log\Console\InitDbCommand')}}, 'dependencies': array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Console\InitDbCommand::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}, 'listener_providers': array{}, 'listeners': array>, 'templates': array{'paths': array{'log': list}}}`.''' count = 1 [[issues]] file = "src/ConfigProvider.php" code = "invalid-return-statement" -message = '''Invalid return type for function `Webware\Log\ConfigProvider::getDependencies`: expected `array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}`, but found `array{'aliases': array{'Psr\EventDispatcher\EventDispatcherInterface': class-string('Phly\EventDispatcher\EventDispatcher'), 'Psr\EventDispatcher\ListenerProviderInterface': class-string('Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate')}, 'delegators': array{'Laminas\Stratigility\Middleware\ErrorHandler': list{class-string('Webware\Log\Container\MezzioErrorHandlerDelegator')}}, 'factories': array{'Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate': class-string('Webware\Log\Container\ListenerProviderAggregateFactory'), 'Psr\Log\LoggerInterface': class-string('Webware\Log\Container\LogFactory'), 'Webware\Log\Handler\PhpDbHandler': class-string('Webware\Log\Handler\PhpDbHandlerFactory'), 'Webware\Log\Http\Middleware\MonologMiddleware': class-string('Webware\Log\Http\Middleware\Container\MonologMiddlewareFactory'), 'Webware\Log\Listener\Psr3LogPsr14Listener': class-string('Webware\Log\Listener\Container\Psr3LogPsr14ListenerFactory'), 'Webware\Log\Processor\LaminasI18nProcessor': class-string('Webware\Log\Processor\LaminasI18nProcessorFactory')}, 'invokables': array{'Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider': class-string('Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider'), 'Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider': class-string('Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider'), 'Webware\Log\Processor\RamseyUuidProcessor': class-string('Webware\Log\Processor\RamseyUuidProcessor')}}`.''' +message = '''Invalid return type for function `Webware\Log\ConfigProvider::getDependencies`: expected `array{'aliases': array{Psr\EventDispatcher\EventDispatcherInterface::class: class-string, Psr\EventDispatcher\ListenerProviderInterface::class: class-string}, 'delegators': array{Laminas\Stratigility\Middleware\ErrorHandler::class: list}, 'factories': array{Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate::class: class-string, Psr\Log\LoggerInterface::class: class-string, Webware\Log\Console\InitDbCommand::class: class-string, Webware\Log\Handler\PhpDbHandler::class: class-string, Webware\Log\Http\Middleware\MonologMiddleware::class: class-string, Webware\Log\Listener\Psr3LogPsr14Listener::class: class-string, Webware\Log\Processor\LaminasI18nProcessor::class: class-string}, 'invokables': array{Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider::class: class-string, Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider::class: class-string, Webware\Log\Processor\RamseyUuidProcessor::class: class-string}}`, but found `array{'aliases': array{'Psr\EventDispatcher\EventDispatcherInterface': class-string('Phly\EventDispatcher\EventDispatcher'), 'Psr\EventDispatcher\ListenerProviderInterface': class-string('Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate')}, 'delegators': array{'Laminas\Stratigility\Middleware\ErrorHandler': list{class-string('Webware\Log\Container\MezzioErrorHandlerDelegator')}}, 'factories': array{'Phly\EventDispatcher\ListenerProvider\ListenerProviderAggregate': class-string('Webware\Log\Container\ListenerProviderAggregateFactory'), 'Psr\Log\LoggerInterface': class-string('Webware\Log\Container\LogFactory'), 'Webware\Log\Console\InitDbCommand': class-string('Webware\Log\Console\Container\InitDbCommandFactory'), 'Webware\Log\Handler\PhpDbHandler': class-string('Webware\Log\Handler\PhpDbHandlerFactory'), 'Webware\Log\Http\Middleware\MonologMiddleware': class-string('Webware\Log\Http\Middleware\Container\MonologMiddlewareFactory'), 'Webware\Log\Listener\Psr3LogPsr14Listener': class-string('Webware\Log\Listener\Container\Psr3LogPsr14ListenerFactory'), 'Webware\Log\Processor\LaminasI18nProcessor': class-string('Webware\Log\Processor\LaminasI18nProcessorFactory')}, 'invokables': array{'Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider': class-string('Phly\EventDispatcher\ListenerProvider\AttachableListenerProvider'), 'Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider': class-string('Phly\EventDispatcher\ListenerProvider\PrioritizedListenerProvider'), 'Webware\Log\Processor\RamseyUuidProcessor': class-string('Webware\Log\Processor\RamseyUuidProcessor')}}`.''' count = 1 [[issues]] diff --git a/composer.json b/composer.json index b460a18..18ce6ca 100644 --- a/composer.json +++ b/composer.json @@ -54,10 +54,12 @@ "phpunit/phpunit": "^13.3.0", "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.2" }, "suggest": { - "php-db/phpdb-mysql": "Required when using PhpDbHandler with a MySQL/MariaDB database" + "php-db/phpdb-mysql": "Required when using PhpDbHandler with a MySQL/MariaDB database", + "webware/webware-console": "Provides the console that runs log:init-db to create the log table" }, "extra": { "laminas": { diff --git a/composer.lock b/composer.lock index 69f882c..b1c7ffc 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": "cc25ff80e3ba9b058ac012187b23610f", + "content-hash": "967d0a2564e1b6e2e4f6270251686893", "packages": [ { "name": "brick/math", @@ -2381,6 +2381,74 @@ }, "time": "2026-09-23T19:46:42+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", @@ -3866,6 +3934,71 @@ }, "time": "2026-09-04T04:45:13+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.3.0", @@ -5008,6 +5141,73 @@ }, "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.3.0", @@ -5074,6 +5274,70 @@ }, "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.3.0", @@ -5537,6 +5801,79 @@ }, "time": "2026-09-25T15:54:11+00:00" }, + { + "name": "php-standard-library/terminal", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-standard-library/terminal.git", + "reference": "22ea2db5532d65d13f51dde97a4ee0d01f8083bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-standard-library/terminal/zipball/22ea2db5532d65d13f51dde97a4ee0d01f8083bf", + "reference": "22ea2db5532d65d13f51dde97a4ee0d01f8083bf", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.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/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": "*" + }, + "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\\Terminal\\": "src/Psl/Terminal/" + } + }, + "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": "Full-featured TUI framework with widgets, events, and diff-based rendering", + "homepage": "https://php-standard-library.dev", + "keywords": [ + "console", + "terminal", + "tui", + "widgets" + ], + "support": { + "source": "https://github.com/php-standard-library/terminal/tree/6.3.0" + }, + "time": "2026-09-25T15:54:11+00:00" + }, { "name": "php-standard-library/type", "version": "6.3.0", @@ -11282,6 +11619,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/glob", "version": "4.7.0", @@ -11331,6 +11727,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.5", @@ -11380,7 +11837,8 @@ "minimum-stability": "dev", "stability-flags": { "php-db/phpdb-mysql": 20, - "roave/security-advisories": 20 + "roave/security-advisories": 20, + "webware/webware-console": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 3353f53..51b1d84 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -23,6 +23,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\ListenerProviderInterface; use Psr\Log\LoggerInterface; +use Webware\Console\ConsoleInterface; /** * @phpstan-type LogDefaults array{ @@ -46,6 +47,7 @@ * LoggerInterface::class: class-string, * Http\Middleware\MonologMiddleware::class: class-string, * Handler\PhpDbHandler::class: class-string, + * Console\InitDbCommand::class: class-string, * Processor\LaminasI18nProcessor::class: class-string, * } * @phpstan-type LogInvokables array{ @@ -68,6 +70,7 @@ * listener_providers: array, * templates: LogTemplatePaths, * LoggerInterface::class: LogDefaults, + * ConsoleInterface::class: array{commands: array}, * } */ class ConfigProvider @@ -109,6 +112,7 @@ public function getDependencies(): array LoggerInterface::class => Container\LogFactory::class, Http\Middleware\MonologMiddleware::class => Http\Middleware\Container\MonologMiddlewareFactory::class, Handler\PhpDbHandler::class => Handler\PhpDbHandlerFactory::class, + Console\InitDbCommand::class => Console\Container\InitDbCommandFactory::class, Processor\LaminasI18nProcessor::class => Processor\LaminasI18nProcessorFactory::class, ], 'invokables' => [ @@ -160,8 +164,13 @@ public function __invoke(): array self::LISTENER_KEY => $this->getListeners(), self::LISTENER_PROVIDER_KEY => [], // 'middleware_pipeline' => $this->getPipelineConfig(), - 'templates' => $this->getTemplates(), - LoggerInterface::class => $this->getConfigDefaults(), + 'templates' => $this->getTemplates(), + LoggerInterface::class => $this->getConfigDefaults(), + ConsoleInterface::class => [ + 'commands' => [ + 'log:init-db' => Console\InitDbCommand::class, + ], + ], ]; } } diff --git a/src/Console/Container/InitDbCommandFactory.php b/src/Console/Container/InitDbCommandFactory.php new file mode 100644 index 0000000..8ca0a5c --- /dev/null +++ b/src/Console/Container/InitDbCommandFactory.php @@ -0,0 +1,47 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Log\Console\Container; + +use PhpDb\Adapter\AdapterInterface; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Exception\LogicException; +use Webware\Log\ConfigProvider; +use Webware\Log\Console\InitDbCommand; + +/** + * The table name is read the way the database handler reads it (the configured logger table, else + * the default), so the command creates the table the handler will write to. + */ +final readonly class InitDbCommandFactory +{ + /** + * @throws ContainerExceptionInterface + * @throws LogicException + * @throws NotFoundExceptionInterface + */ + public function __invoke(ContainerInterface $container): InitDbCommand + { + /** @var array $config */ + $config = $container->get('config'); + + return new InitDbCommand( + adapter: $container->get(AdapterInterface::class), + table : $config[LoggerInterface::class]['table'] ?? new ConfigProvider()->getConfigDefaults()['table'], + ); + } +} diff --git a/src/Console/Ddl/Column/LongText.php b/src/Console/Ddl/Column/LongText.php new file mode 100644 index 0000000..1d3b671 --- /dev/null +++ b/src/Console/Ddl/Column/LongText.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 Webware\Log\Console\Ddl\Column; + +use PhpDb\Sql\Ddl\Column\Text; + +/** + * phpdb ships TEXT but no LONGTEXT; the log message and context can exceed 64 KB. + * + * @internal + */ +final class LongText extends Text +{ + protected string $type = 'LONGTEXT'; +} diff --git a/src/Console/InitDbCommand.php b/src/Console/InitDbCommand.php new file mode 100644 index 0000000..9a36c1d --- /dev/null +++ b/src/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 Webware\Log\Console; + +use Override; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\DropTable; +use PhpDb\Sql\Exception\ExceptionInterface as SqlException; +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; +use Webware\Log\Console\Schema\LogSchema; + +#[AsCommand( + name : 'log:init-db', + description: 'Create the table the database log handler writes to', +)] +final class InitDbCommand extends Command +{ + private readonly LogSchema $schema; + + /** + * @throws ConsoleLogicException + */ + public function __construct( + private readonly AdapterInterface $adapter, + private readonly string $table, + ) { + $this->schema = new LogSchema(); + + parent::__construct(); + } + + /** + * @throws ConsoleInvalidArgumentException + */ + #[Override] + protected function configure(): void + { + $this->addOption( + name : 'drop', + mode : InputOption::VALUE_NONE, + description: 'Drop the log table before recreating it', + ); + } + + /** + * @throws ConsoleInvalidArgumentException + * @throws SqlException + */ + #[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->logTable($this->table)); + + $output->writeln('Log table initialized.'); + + return Command::SUCCESS; + } + + /** + * @throws SqlException + */ + private function executeDdl(Sql $sql, CreateTable|DropTable $ddl): void + { + $this->adapter->query( + $sql->buildSqlString($ddl), + AdapterInterface::QUERY_MODE_EXECUTE, + ); + } +} diff --git a/src/Console/Schema/LogSchema.php b/src/Console/Schema/LogSchema.php new file mode 100644 index 0000000..229565f --- /dev/null +++ b/src/Console/Schema/LogSchema.php @@ -0,0 +1,110 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Webware\Log\Console\Schema; + +use PhpDb\Sql\Ddl\Column\Char; +use PhpDb\Sql\Ddl\Column\Integer; +use PhpDb\Sql\Ddl\Column\Varchar; +use PhpDb\Sql\Ddl\Constraint\PrimaryKey; +use PhpDb\Sql\Ddl\Constraint\UniqueKey; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Ddl\DropTable; +use PhpDb\Sql\Ddl\Index\Index; +use PhpDb\Sql\Literal; +use Webware\Log\Console\Ddl\Column\LongText; + +/** + * Builds the table {@see \Webware\Log\Handler\PhpDbHandler} writes to. + * + * The columns are exactly the ones the handler inserts: channel, level, uuid, message, time, + * user_identifier and context. The table name is the caller's, because the handler takes it from + * configuration and the two must agree. + * + * @internal + */ +final class LogSchema +{ + public function dropTable(string $table): DropTable + { + return new DropTable(table: $table)->ifExists(); + } + + public function logTable(string $table): CreateTable + { + $createTable = new CreateTable(table: $table)->ifNotExists(); + + $createTable->addColumn( + new Integer( + name : 'id', + nullable: false, + )->setOptions(options: ['unsigned' => true, 'autoincrement' => true]), + ); + $createTable->addColumn(new Char( + name : 'uuid', + length : 36, + nullable: true, + default : null, + )); + $createTable->addColumn(new Varchar( + name : 'channel', + length : 255, + nullable: false, + )); + $createTable->addColumn(new Varchar( + name : 'level', + length : 9, + nullable: false, + )); + $createTable->addColumn(new Varchar( + name : 'user_identifier', + length : 320, + nullable: true, + default : null, + )); + $createTable->addColumn(new LongText( + name : 'message', + nullable: false, + )); + $createTable->addColumn( + new Integer( + name : 'time', + nullable: false, + )->setOptions(options: ['unsigned' => true]), + ); + $createTable->addColumn(new LongText( + name : 'context', + nullable: true, + default : null, + )); + + $createTable->addConstraint(new PrimaryKey(columns: 'id')); + $createTable->addConstraint(new UniqueKey( + columns: 'uuid', + name : 'uuid', + )); + $createTable->addConstraint(new Index( + columns: 'channel', + name : 'ChannelIndex', + )); + + $createTable->setOptions(options: [ + 'engine' => new Literal(literal: 'InnoDB'), + 'default charset' => new Literal(literal: 'utf8mb4'), + 'collate' => new Literal(literal: 'utf8mb4_general_ci'), + ]); + + return $createTable; + } +} diff --git a/test/integration/Console/InitDbCommandTest.php b/test/integration/Console/InitDbCommandTest.php new file mode 100644 index 0000000..9e54510 --- /dev/null +++ b/test/integration/Console/InitDbCommandTest.php @@ -0,0 +1,188 @@ + + * and contributors. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace WebwareTestIntegration\Log\Console; + +use DateTimeImmutable; +use Monolog\Level; +use Monolog\LogRecord; +use Override; +use PDO; +use PhpDb\Adapter\Adapter; +use PhpDb\Adapter\AdapterInterface; +use PhpDb\Adapter\Driver\Pdo\Result; +use PhpDb\Adapter\Driver\Pdo\Statement; +use PhpDb\Mysql\AdapterPlatform; +use PhpDb\Mysql\Pdo\Connection; +use PhpDb\Mysql\Pdo\Driver; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Webware\Log\Console\InitDbCommand; +use Webware\Log\Handler\PhpDbHandler; + +use function getenv; +use function sprintf; + +#[CoversClass(InitDbCommand::class)] +#[CoversMethod(InitDbCommand::class, 'execute')] +#[RequiresPhpExtension('pdo_mysql')] +final class InitDbCommandTest extends TestCase +{ + private const string TABLE = 'init_db_probe'; + + private AdapterInterface $adapter; + + private PDO $pdo; + + #[Test] + public function createdTableMatchesTheFixtureTheHandlerIsTestedAgainst(): void + { + $this->runCommand(['--drop' => true]); + + self::assertSame( + $this->describe('log'), + $this->describe(self::TABLE), + ); + } + + #[Test] + public function handlerWritesToTheCreatedTable(): void + { + $this->runCommand(['--drop' => true]); + + new PhpDbHandler($this->adapter, self::TABLE)->handle(new LogRecord( + datetime : new DateTimeImmutable(), + channel : 'app', + level : Level::Info, + message : 'written to the created table', + formatted: 'written to the created table', + )); + + self::assertSame( + '1', + (string) $this->pdo->query(sprintf('SELECT COUNT(*) FROM `%s`', self::TABLE))?->fetchColumn(), + ); + } + + #[Test] + public function runningWithDropEmptiesTheTable(): void + { + $this->runCommand(['--drop' => true]); + $this->pdo->exec(sprintf( + "INSERT INTO `%s` (channel, level, message, time) VALUES ('app', 'INFO', 'gone', 1)", + self::TABLE, + )); + + $this->runCommand(['--drop' => true]); + + self::assertSame( + '0', + (string) $this->pdo->query(sprintf('SELECT COUNT(*) FROM `%s`', self::TABLE))?->fetchColumn(), + ); + } + + #[Test] + public function runningWithoutDropKeepsExistingRows(): void + { + $this->runCommand(['--drop' => true]); + $this->pdo->exec(sprintf( + "INSERT INTO `%s` (channel, level, message, time) VALUES ('app', 'INFO', 'kept', 1)", + self::TABLE, + )); + + self::assertSame(Command::SUCCESS, $this->runCommand([])); + self::assertSame( + '1', + (string) $this->pdo->query(sprintf('SELECT COUNT(*) FROM `%s`', self::TABLE))?->fetchColumn(), + ); + } + + #[Override] + protected function setUp(): void + { + $hostname = $this->env('TESTS_ADAPTER_MYSQL_HOSTNAME', 'localhost'); + $username = $this->env('TESTS_ADAPTER_MYSQL_USERNAME', 'root'); + $password = $this->env('TESTS_ADAPTER_MYSQL_PASSWORD', ''); + $database = $this->env('TESTS_ADAPTER_MYSQL_DATABASE', 'webware_log_test'); + $port = (int) $this->env('TESTS_ADAPTER_MYSQL_PORT', '3306'); + + $driver = new Driver( + new Connection([ + 'hostname' => $hostname, + 'port' => $port, + 'username' => $username, + 'password' => $password, + 'database' => $database, + ]), + new Statement(), + new Result(), + ); + $this->adapter = new Adapter($driver, new AdapterPlatform($driver)); + + $this->pdo = new PDO( + sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $hostname, $port, $database), + $username, + $password, + [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], + ); + } + + #[Override] + protected function tearDown(): void + { + $this->pdo->exec(sprintf('DROP TABLE IF EXISTS `%s`', self::TABLE)); + } + + /** + * @return list> + */ + private function describe(string $table): array + { + $statement = $this->pdo->prepare( + 'SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_KEY, EXTRA, COLLATION_NAME' + . ' FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?' + . ' ORDER BY ORDINAL_POSITION', + ); + $statement->execute([$table]); + + /** @var list> */ + return $statement->fetchAll(PDO::FETCH_ASSOC); + } + + private function env(string $name, string $default): string + { + $value = getenv($name); + + return false === $value ? $default : $value; + } + + /** + * @param array $input + */ + private function runCommand(array $input): int + { + $tester = new CommandTester(new InitDbCommand( + adapter: $this->adapter, + table : self::TABLE, + )); + $tester->execute($input); + + return $tester->getStatusCode(); + } +} diff --git a/test/unit/ConfigProviderTest.php b/test/unit/ConfigProviderTest.php index 94e9c6d..8d8d4d5 100644 --- a/test/unit/ConfigProviderTest.php +++ b/test/unit/ConfigProviderTest.php @@ -22,7 +22,10 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\ListenerProviderInterface; use Psr\Log\LoggerInterface; +use Webware\Console\ConsoleInterface; use Webware\Log\ConfigProvider; +use Webware\Log\Console\Container\InitDbCommandFactory; +use Webware\Log\Console\InitDbCommand; use Webware\Log\Event\LogEvent; use Webware\Log\Listener\Psr3LogPsr14Listener; use Webware\Log\LogChannel; @@ -108,6 +111,17 @@ public function getDependenciesAliasesListenerProviderInterface(): void $this->assertArrayHasKey(ListenerProviderInterface::class, $deps['aliases']); } + /** + * @throws \PHPUnit\Exception + */ + #[Test] + public function getDependenciesRegistersTheInitDbCommandFactory(): void + { + $deps = $this->provider->getDependencies(); + + $this->assertSame(InitDbCommandFactory::class, $deps['factories'][InitDbCommand::class]); + } + /** * @throws \PHPUnit\Exception */ @@ -187,6 +201,20 @@ public function invokeNestedLoggerConfigMatchesGetConfigDefaults(): void $this->assertSame($this->provider->getConfigDefaults(), $config[LoggerInterface::class]); } + /** + * @throws \PHPUnit\Exception + */ + #[Test] + public function invokeRegistersTheLogInitDbCommandForConsoleDiscovery(): void + { + $config = ($this->provider)(); + + $this->assertSame( + ['commands' => ['log:init-db' => InitDbCommand::class]], + $config[ConsoleInterface::class], + ); + } + /** * @throws \PHPUnit\Exception */ diff --git a/test/unit/Console/Container/InitDbCommandFactoryTest.php b/test/unit/Console/Container/InitDbCommandFactoryTest.php new file mode 100644 index 0000000..d1b15ce --- /dev/null +++ b/test/unit/Console/Container/InitDbCommandFactoryTest.php @@ -0,0 +1,73 @@ +container( + config : [], + executed: $executed, + )); + + self::assertInstanceOf(InitDbCommand::class, $command); + + new CommandTester($command)->execute([]); + + self::assertStringContainsString('`log`', $executed[0]); + } + + #[Test] + public function invokeUsesTheTableTheHandlerIsConfiguredWith(): void + { + $executed = []; + $command = (new InitDbCommandFactory())($this->container( + config : [LoggerInterface::class => ['table' => 'app_log']], + executed: $executed, + )); + + new CommandTester($command)->execute([]); + + self::assertStringContainsString('`app_log`', $executed[0]); + } + + /** + * @param array $config + * @param list $executed + */ + private function container(array $config, array &$executed): ContainerInterface + { + $adapter = $this->ddlAdapter($executed); + $container = $this->createStub(ContainerInterface::class); + $container->method('get') + ->willReturnCallback( + static fn(string $id): mixed => match ($id) { + 'config' => $config, + AdapterInterface::class => $adapter, + }, + ); + + return $container; + } +} diff --git a/test/unit/Console/Ddl/Column/LongTextTest.php b/test/unit/Console/Ddl/Column/LongTextTest.php new file mode 100644 index 0000000..80b88f3 --- /dev/null +++ b/test/unit/Console/Ddl/Column/LongTextTest.php @@ -0,0 +1,41 @@ +addColumn(new LongText( + name : 'message', + 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( + '`message` LONGTEXT NOT NULL', + $platform->getSqlString(new AdapterPlatform($driver)), + ); + } +} diff --git a/test/unit/Console/InitDbCommandTest.php b/test/unit/Console/InitDbCommandTest.php new file mode 100644 index 0000000..ad1470e --- /dev/null +++ b/test/unit/Console/InitDbCommandTest.php @@ -0,0 +1,91 @@ +ddlAdapter($executed), + table : 'log', + ); + + self::assertSame('log:init-db', $command->getName()); + self::assertTrue($command->getDefinition()->hasOption('drop')); + } + + #[Test] + public function executeCreatesTheLogTable(): void + { + $executed = []; + $command = new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'log', + ); + + $tester = new CommandTester($command); + $tester->execute([]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertCount(1, $executed); + self::assertStringContainsString('CREATE TABLE IF NOT EXISTS `log`', $executed[0]); + self::assertStringContainsString('Creating table log...', $tester->getDisplay()); + self::assertStringContainsString('Log table initialized.', $tester->getDisplay()); + self::assertStringNotContainsString('Dropping table', $tester->getDisplay()); + } + + #[Test] + public function executeDropsTheTableFirstWhenRequested(): void + { + $executed = []; + $command = new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'log', + ); + + $tester = new CommandTester($command); + $tester->execute(['--drop' => true]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertCount(2, $executed); + self::assertStringContainsString('DROP TABLE IF EXISTS `log`', $executed[0]); + self::assertStringContainsString('CREATE TABLE IF NOT EXISTS `log`', $executed[1]); + self::assertStringContainsString('Dropping table log...', $tester->getDisplay()); + } + + #[Test] + public function executeUsesTheConfiguredTableName(): void + { + $executed = []; + $command = new InitDbCommand( + adapter: $this->ddlAdapter($executed), + table : 'app_log', + ); + + new CommandTester($command)->execute([]); + + self::assertSame(1, count($executed)); + self::assertStringContainsString('`app_log`', $executed[0]); + } +} diff --git a/test/unit/Console/Schema/LogSchemaTest.php b/test/unit/Console/Schema/LogSchemaTest.php new file mode 100644 index 0000000..249076f --- /dev/null +++ b/test/unit/Console/Schema/LogSchemaTest.php @@ -0,0 +1,162 @@ +renderSql(new LogSchema()->dropTable('log')); + + self::assertStringContainsString('DROP TABLE IF EXISTS `log`', $sql); + } + + #[Test] + public function logTableCarriesTheKeysAndTheChannelIndex(): void + { + $constraints = $this->constraints(new LogSchema()->logTable('log')); + + self::assertCount(3, $constraints); + self::assertInstanceOf(PrimaryKey::class, $constraints[0]); + self::assertInstanceOf(UniqueKey::class, $constraints[1]); + self::assertSame('uuid', $constraints[1]->getName()); + self::assertInstanceOf(Index::class, $constraints[2]); + self::assertSame('ChannelIndex', $constraints[2]->getName()); + } + + #[Test] + public function logTableHasExactlyTheColumnsTheHandlerInserts(): void + { + $names = array_map( + static fn(ColumnInterface $column): string => $column->getName(), + $this->columns(new LogSchema()->logTable('log')), + ); + + self::assertSame( + ['id', 'uuid', 'channel', 'level', 'user_identifier', 'message', 'time', 'context'], + $names, + ); + } + + #[Test] + public function logTableOptionsSetEngineCharsetAndCollation(): void + { + $options = new LogSchema()->logTable('log') + ->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_general_ci', $options['collate']->getLiteral()); + } + + #[Test] + public function logTableRendersTheExactStatement(): void + { + $sql = (string) preg_replace('/\s+/', ' ', $this->renderSql(new LogSchema()->logTable('log'))); + + self::assertSame( + 'CREATE TABLE IF NOT EXISTS `log` ( ' + . '`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT, ' + . '`uuid` CHAR(36) NULL DEFAULT NULL, ' + . '`channel` VARCHAR(255) NOT NULL, ' + . '`level` VARCHAR(9) NOT NULL, ' + . '`user_identifier` VARCHAR(320) NULL DEFAULT NULL, ' + . '`message` LONGTEXT NOT NULL, ' + . '`time` INTEGER UNSIGNED NOT NULL, ' + . '`context` LONGTEXT NULL DEFAULT NULL , ' + . 'PRIMARY KEY (`id`), ' + . 'CONSTRAINT `uuid` UNIQUE (`uuid`), ' + . 'INDEX `ChannelIndex`(`channel`) ' + . ') ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_general_ci', + $sql, + ); + } + + #[Test] + public function logTableRendersValidMysqlDdl(): void + { + $sql = $this->renderSql(new LogSchema()->logTable('log')); + + self::assertStringContainsString('CREATE TABLE IF NOT EXISTS `log`', $sql); + self::assertStringContainsString('UNSIGNED', $sql); + self::assertStringContainsString('AUTO_INCREMENT', $sql); + self::assertStringContainsString('CHAR(36)', $sql); + self::assertStringContainsString('VARCHAR(255)', $sql); + self::assertStringContainsString('VARCHAR(9)', $sql); + self::assertStringContainsString('VARCHAR(320)', $sql); + self::assertStringContainsString('LONGTEXT', $sql); + self::assertStringContainsString('PRIMARY KEY', $sql); + self::assertStringContainsString('UNIQUE', $sql); + self::assertStringContainsString('ChannelIndex', $sql); + self::assertStringContainsString('ENGINE = InnoDB', $sql); + self::assertStringContainsString('utf8mb4_general_ci', $sql); + } + + #[Test] + public function logTableUsesTheTableNameItIsGiven(): void + { + $sql = $this->renderSql(new LogSchema()->logTable('app_log')); + + self::assertStringContainsString('`app_log`', $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/Support/DdlAdapterTrait.php b/test/unit/Support/DdlAdapterTrait.php new file mode 100644 index 0000000..8f5dbd3 --- /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; + } +} From 8e79c95d537e463f5ecb41562b9a4f47f1985be2 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Wed, 30 Sep 2026 17:02:13 -0500 Subject: [PATCH 2/2] test: pass literal arguments by name in the schema test CI lint (literal-named-argument) rejected the positional preg_replace arguments. Signed-off-by: Joey Smith --- test/unit/Console/Schema/LogSchemaTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/unit/Console/Schema/LogSchemaTest.php b/test/unit/Console/Schema/LogSchemaTest.php index 249076f..c8dc2f1 100644 --- a/test/unit/Console/Schema/LogSchemaTest.php +++ b/test/unit/Console/Schema/LogSchemaTest.php @@ -82,7 +82,11 @@ public function logTableOptionsSetEngineCharsetAndCollation(): void #[Test] public function logTableRendersTheExactStatement(): void { - $sql = (string) preg_replace('/\s+/', ' ', $this->renderSql(new LogSchema()->logTable('log'))); + $sql = (string) preg_replace( + pattern : '/\s+/', + replacement: ' ', + subject : $this->renderSql(new LogSchema()->logTable('log')), + ); self::assertSame( 'CREATE TABLE IF NOT EXISTS `log` ( '