From dff8ca3eb05b44cd379d0ea3a98d4cdedd97f89f Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Tue, 27 Aug 2024 18:13:45 +1200 Subject: [PATCH] NEW Refactor CLI interaction with Silverstripe app - Turn sake into a symfony/console app - Avoid using HTTPRequest for CLI interaction - Implement abstract hybrid execution path --- _config/dev.yml | 22 +- _config/extensions.yml | 4 +- _config/logging.yml | 4 +- _config/mailer.yml | 4 +- _config/sake.yml | 6 + bin/sake | 21 + cli-script.php | 35 -- client/styles/debug.css | 9 +- composer.json | 5 +- sake | 119 ---- src/Cli/ArrayCommandLoader.php | 52 ++ src/Cli/DevCommandLoader.php | 16 + src/Cli/DevTaskLoader.php | 20 + src/Cli/HybridCommandCliWrapper.php | 51 ++ src/Cli/HybridCommandLoader.php | 75 +++ src/Cli/InjectorCommandLoader.php | 32 ++ src/Cli/Sake.php | 88 +++ src/Core/Injector/Injector.php | 2 +- src/Dev/BuildTask.php | 91 +-- src/Dev/DevBuildController.php | 83 --- src/Dev/DevConfigController.php | 199 ------- src/Dev/DevConfirmationController.php | 1 - src/Dev/DevelopmentAdmin.php | 331 ++++++----- .../HybridExecution/AnsiToHtmlConverter.php | 129 +++++ src/Dev/HybridExecution/Command/DevBuild.php | 339 +++++++++++ .../Command/DevBuildCleanup.php | 88 +++ .../Command/DevBuildDefaults.php | 47 ++ src/Dev/HybridExecution/Command/DevConfig.php | 64 +++ .../Command/DevConfigAudit.php | 90 +++ .../HybridExecution/Command/HybridCommand.php | 146 +++++ .../HybridExecution/HtmlOutputFormatter.php | 58 ++ src/Dev/HybridExecution/HttpRequestInput.php | 103 ++++ src/Dev/HybridExecution/HybridOutput.php | 220 +++++++ src/Dev/MigrationTask.php | 84 +-- src/Dev/SapphireTest.php | 2 +- src/Dev/State/ExtensionTestState.php | 2 +- src/Dev/TaskRunner.php | 52 +- src/Dev/Tasks/CleanupTestDatabasesTask.php | 31 +- src/Dev/Tasks/i18nTextCollectorTask.php | 72 +-- src/Dev/TestMailer.php | 2 +- ...minExtension.php => DevBuildExtension.php} | 13 +- ...tputHandler.php => ErrorOutputHandler.php} | 26 +- src/ORM/ArrayLib.php | 26 + src/ORM/Connect/TempDatabase.php | 2 +- src/ORM/DataObject.php | 2 +- src/ORM/DatabaseAdmin.php | 538 ------------------ src/ORM/FieldType/DBDatetime.php | 64 +++ src/Security/Security.php | 2 +- src/i18n/TextCollection/i18nTextCollector.php | 1 - tests/php/Dev/BuildTaskTest.php | 10 +- .../TaskRunnerTest_AbstractTask.php | 6 +- .../TaskRunnerTest_DisabledTask.php | 6 +- .../TaskRunnerTest_EnabledTask.php | 6 +- ...lerTest.php => ErrorOutputHandlerTest.php} | 37 +- 54 files changed, 2158 insertions(+), 1380 deletions(-) create mode 100644 _config/sake.yml create mode 100755 bin/sake delete mode 100755 cli-script.php delete mode 100755 sake create mode 100644 src/Cli/ArrayCommandLoader.php create mode 100644 src/Cli/DevCommandLoader.php create mode 100644 src/Cli/DevTaskLoader.php create mode 100644 src/Cli/HybridCommandCliWrapper.php create mode 100644 src/Cli/HybridCommandLoader.php create mode 100644 src/Cli/InjectorCommandLoader.php create mode 100644 src/Cli/Sake.php delete mode 100644 src/Dev/DevBuildController.php delete mode 100644 src/Dev/DevConfigController.php create mode 100644 src/Dev/HybridExecution/AnsiToHtmlConverter.php create mode 100644 src/Dev/HybridExecution/Command/DevBuild.php create mode 100644 src/Dev/HybridExecution/Command/DevBuildCleanup.php create mode 100644 src/Dev/HybridExecution/Command/DevBuildDefaults.php create mode 100644 src/Dev/HybridExecution/Command/DevConfig.php create mode 100644 src/Dev/HybridExecution/Command/DevConfigAudit.php create mode 100644 src/Dev/HybridExecution/Command/HybridCommand.php create mode 100644 src/Dev/HybridExecution/HtmlOutputFormatter.php create mode 100644 src/Dev/HybridExecution/HttpRequestInput.php create mode 100644 src/Dev/HybridExecution/HybridOutput.php rename src/Dev/Validation/{DatabaseAdminExtension.php => DevBuildExtension.php} (55%) rename src/Logging/{HTTPOutputHandler.php => ErrorOutputHandler.php} (86%) delete mode 100644 src/ORM/DatabaseAdmin.php rename tests/php/Logging/{HTTPOutputHandlerTest.php => ErrorOutputHandlerTest.php} (84%) diff --git a/_config/dev.yml b/_config/dev.yml index 4c1636bc4b5..18201dcafa5 100644 --- a/_config/dev.yml +++ b/_config/dev.yml @@ -2,21 +2,19 @@ Name: DevelopmentAdmin --- SilverStripe\Dev\DevelopmentAdmin: - registered_controllers: - build: - controller: SilverStripe\Dev\DevBuildController - links: - build: 'Build/rebuild this environment. Call this whenever you have updated your project sources' + commands: + build: 'SilverStripe\Dev\HybridExecution\Command\DevBuild' + 'build:cleanup': 'SilverStripe\Dev\HybridExecution\Command\DevBuildCleanup' + 'build:defaults': 'SilverStripe\Dev\HybridExecution\Command\DevBuildDefaults' + config: 'SilverStripe\Dev\HybridExecution\Command\DevConfig' + 'config:audit': 'SilverStripe\Dev\HybridExecution\Command\DevConfigAudit' + controllers: tasks: - controller: SilverStripe\Dev\TaskRunner - links: - tasks: 'See a list of build tasks to run' + class: 'SilverStripe\Dev\TaskRunner' + description: 'See a list of build tasks to run' + registered_controllers: confirm: controller: SilverStripe\Dev\DevConfirmationController - config: - controller: Silverstripe\Dev\DevConfigController - links: - config: 'View the current config, useful for debugging' SilverStripe\Dev\CSSContentParser: disable_xml_external_entities: true diff --git a/_config/extensions.yml b/_config/extensions.yml index 1d77a36dc12..0cae14148f6 100644 --- a/_config/extensions.yml +++ b/_config/extensions.yml @@ -7,6 +7,6 @@ SilverStripe\Security\Member: SilverStripe\Security\Group: extensions: - SilverStripe\Security\InheritedPermissionFlusher -SilverStripe\ORM\DatabaseAdmin: +SilverStripe\Dev\HybridExecution\Command\DevBuild: extensions: - - SilverStripe\Dev\Validation\DatabaseAdminExtension + - SilverStripe\Dev\Validation\DevBuildExtension diff --git a/_config/logging.yml b/_config/logging.yml index b729fd33710..d49e30b14fc 100644 --- a/_config/logging.yml +++ b/_config/logging.yml @@ -52,7 +52,7 @@ Only: # Dev handler outputs detailed information including notices SilverStripe\Core\Injector\Injector: Monolog\Handler\HandlerInterface: - class: SilverStripe\Logging\HTTPOutputHandler + class: SilverStripe\Logging\ErrorOutputHandler constructor: - "notice" properties: @@ -66,7 +66,7 @@ Except: # CLI errors still show full details SilverStripe\Core\Injector\Injector: Monolog\Handler\HandlerInterface: - class: SilverStripe\Logging\HTTPOutputHandler + class: SilverStripe\Logging\ErrorOutputHandler constructor: - "error" properties: diff --git a/_config/mailer.yml b/_config/mailer.yml index d930bac90fd..2503efa1378 100644 --- a/_config/mailer.yml +++ b/_config/mailer.yml @@ -6,7 +6,7 @@ SilverStripe\Core\Injector\Injector: class: Symfony\Component\Mailer\Mailer constructor: transport: '%$Symfony\Component\Mailer\Transport\TransportInterface' - Symfony\Component\EventDispatcher\EventDispatcherInterface.mailer: + Symfony\Contracts\EventDispatcher\EventDispatcherInterface.mailer: class: Symfony\Component\EventDispatcher\EventDispatcher calls: - [addSubscriber, ['%$SilverStripe\Control\Email\MailerSubscriber']] @@ -14,4 +14,4 @@ SilverStripe\Core\Injector\Injector: factory: SilverStripe\Control\Email\TransportFactory constructor: dsn: 'sendmail://default' - dispatcher: '%$Symfony\Component\EventDispatcher\EventDispatcherInterface.mailer' + dispatcher: '%$Symfony\Contracts\EventDispatcher\EventDispatcherInterface.mailer' diff --git a/_config/sake.yml b/_config/sake.yml new file mode 100644 index 00000000000..9a29295f70d --- /dev/null +++ b/_config/sake.yml @@ -0,0 +1,6 @@ +--- +Name: sake-config +--- +SilverStripe\Core\Injector\Injector: + Symfony\Contracts\EventDispatcher\EventDispatcherInterface.sake: + class: Symfony\Component\EventDispatcher\EventDispatcher diff --git a/bin/sake b/bin/sake new file mode 100755 index 00000000000..c9a74a8f709 --- /dev/null +++ b/bin/sake @@ -0,0 +1,21 @@ +#!/usr/bin/env php +addCommands([ + // probably do this inside the sake app itself though + // TODO: + // - flush + // - navigate (use HTTPRequest and spin off a "web" request from CLI) +]); +$sake->run(); diff --git a/cli-script.php b/cli-script.php deleted file mode 100755 index 879b2de6546..00000000000 --- a/cli-script.php +++ /dev/null @@ -1,35 +0,0 @@ -handle($request); - -$response->output(); diff --git a/client/styles/debug.css b/client/styles/debug.css index bb3ac83912f..4fdd5c81831 100644 --- a/client/styles/debug.css +++ b/client/styles/debug.css @@ -113,7 +113,6 @@ a:active { } /* Content types */ -.build, .options, .trace { position: relative; @@ -128,19 +127,19 @@ a:active { line-height: 1.3; } -.build .success { +.options .success { color: #2b6c2d; } -.build .error { +.options .error { color: #d30000; } -.build .warning { +.options .warning { color: #8a6d3b; } -.build .info { +.options .info { color: #0073c1; } diff --git a/composer.json b/composer.json index 8c31c99f12b..03f98562265 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ } ], "bin": [ - "sake" + "bin/sake" ], "require": { "php": "^8.3", @@ -36,12 +36,14 @@ "psr/container": "^1.1 || ^2.0", "psr/http-message": "^1", "sebastian/diff": "^4.0", + "sensiolabs/ansi-to-html": "^1.2", "silverstripe/config": "^3", "silverstripe/assets": "^3", "silverstripe/vendor-plugin": "^2", "sminnee/callbacklist": "^0.1.1", "symfony/cache": "^6.1", "symfony/config": "^6.1", + "symfony/console": "^7.0", "symfony/dom-crawler": "^6.1", "symfony/filesystem": "^6.1", "symfony/http-foundation": "^6.1", @@ -85,6 +87,7 @@ }, "autoload": { "psr-4": { + "SilverStripe\\Cli\\": "src/Cli/", "SilverStripe\\Control\\": "src/Control/", "SilverStripe\\Control\\Tests\\": "tests/php/Control/", "SilverStripe\\Core\\": "src/Core/", diff --git a/sake b/sake deleted file mode 100755 index 59103445b54..00000000000 --- a/sake +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env bash - -# Check for an argument -if [ ${1:-""} = "" ]; then - echo "SilverStripe Sake - -Usage: $0 (command-url) (params) -Executes a SilverStripe command" - exit 1 -fi - -command -v which >/dev/null 2>&1 -if [ $? -ne 0 ]; then - echo "Error: sake requires the 'which' command to operate." >&2 - exit 1 -fi - -# find the silverstripe installation, looking first at sake -# bin location, but falling back to current directory -sakedir=`dirname $0` -directory="$PWD" -if [ -f "$sakedir/cli-script.php" ]; then - # Calling sake from vendor/silverstripe/framework/sake - framework="$sakedir" - base="$sakedir/../../.." -elif [ -f "$sakedir/../silverstripe/framework/cli-script.php" ]; then - # Calling sake from vendor/bin/sake - framework="$sakedir/../silverstripe/framework" - base="$sakedir/../.." -elif [ -f "$directory/vendor/silverstripe/framework/cli-script.php" ]; then - # Vendor framework (from base) if sake installed globally - framework="$directory/vendor/silverstripe/framework" - base=. -elif [ -f "$directory/framework/cli-script.php" ]; then - # Legacy directory (from base) if sake installed globally - framework="$directory/framework" - base=. -else - echo "Can't find cli-script.php in $sakedir" - exit 1 -fi - -# Find the PHP binary -for candidatephp in php php5; do - if [ "`which $candidatephp 2>/dev/null`" -a -f "`which $candidatephp 2>/dev/null`" ]; then - php=`which $candidatephp 2>/dev/null` - break - fi -done -if [ "$php" = "" ]; then - echo "Can't find any php binary" - exit 2 -fi - -################################################################################################ -## Installation to /usr/bin - -if [ "$1" = "installsake" ]; then - echo "Installing sake to /usr/local/bin..." - rm -rf /usr/local/bin/sake - cp $0 /usr/local/bin - exit 0 -fi - -################################################################################################ -## Process control - -if [ "$1" = "-start" ]; then - if [ "`which daemon`" = "" ]; then - echo "You need to install the 'daemon' tool. In debian, go 'sudo apt-get install daemon'" - exit 1 - fi - - if [ ! -f $base/$2.pid ]; then - echo "Starting service $2 $3" - touch $base/$2.pid - pidfile=`realpath $base/$2.pid` - - outlog=$base/$2.log - errlog=$base/$2.err - - echo "Logging to $outlog" - - sake=`realpath $0` - base=`realpath $base` - - # if third argument is not explicitly given, copy from second argument - if [ "$3" = "" ]; then - url=$2 - else - url=$3 - fi - - processname=$2 - - daemon -n $processname -r -D $base --pidfile=$pidfile --stdout=$outlog --stderr=$errlog $sake $url - else - echo "Service $2 seems to already be running" - fi - exit 0 -fi - -if [ "$1" = "-stop" ]; then - pidfile=$base/$2.pid - if [ -f $pidfile ]; then - echo "Stopping service $2" - - kill -KILL `cat $pidfile` - unlink $pidfile - else - echo "Service $2 doesn't seem to be running." - fi - exit 0 -fi - -################################################################################################ -## Basic execution - -"$php" "$framework/cli-script.php" "${@}" diff --git a/src/Cli/ArrayCommandLoader.php b/src/Cli/ArrayCommandLoader.php new file mode 100644 index 00000000000..ec518106d3d --- /dev/null +++ b/src/Cli/ArrayCommandLoader.php @@ -0,0 +1,52 @@ + + */ + private array $loaders = []; + + public function __construct(array $loaders) + { + $this->loaders = $loaders; + } + + public function get(string $name): Command + { + foreach ($this->loaders as $loader) { + if ($loader->has($name)) { + return $loader->get($name); + } + } + throw new CommandNotFoundException("Can't find command $name"); + } + + public function has(string $name): bool + { + foreach ($this->loaders as $loader) { + if ($loader->has($name)) { + return true; + } + } + return false; + } + + public function getNames(): array + { + $names = []; + foreach ($this->loaders as $loader) { + $names = array_merge($names, $loader->getNames()); + } + return array_unique($names); + } +} diff --git a/src/Cli/DevCommandLoader.php b/src/Cli/DevCommandLoader.php new file mode 100644 index 00000000000..3d0259c5020 --- /dev/null +++ b/src/Cli/DevCommandLoader.php @@ -0,0 +1,16 @@ +getCommands(); + } +} diff --git a/src/Cli/DevTaskLoader.php b/src/Cli/DevTaskLoader.php new file mode 100644 index 00000000000..8b6136040f2 --- /dev/null +++ b/src/Cli/DevTaskLoader.php @@ -0,0 +1,20 @@ +getTaskList() as $name => $class) { + $commands['dev:tasks:' . $name] = $class; + }; + return $commands; + } +} diff --git a/src/Cli/HybridCommandCliWrapper.php b/src/Cli/HybridCommandCliWrapper.php new file mode 100644 index 00000000000..f34a9c1741a --- /dev/null +++ b/src/Cli/HybridCommandCliWrapper.php @@ -0,0 +1,51 @@ +command = $command; + parent::__construct($name); + $this->setDefinition(new InputDefinition($this->command->getOptions())); + $this->setAliases([$this->makeAlias($this->getName())]); + $this->setDescription($this->command::getDescription()); + } + + public function run(InputInterface $input, OutputInterface $output): int + { + $hybridOutput = HybridOutput::create( + HybridOutput::CONTEXT_CLI, + $output->getVerbosity(), + $output->isDecorated(), + $output + ); + // Output the title, or if there's a subtitle, output that instead for historical reasons. + $title = $this->command->getTitle(); + if (ClassInfo::hasMethod($this->command, 'getSubTitle')) { + $title = $this->command->getSubtitle(); + } + // TODO make the title look a lil nicer + $hybridOutput->writeln([$title, '--------']); + return $this->command->run($input, $hybridOutput); + } + + private function makeAlias(string $name): string + { + return str_replace(':', '/', $name); + } +} diff --git a/src/Cli/HybridCommandLoader.php b/src/Cli/HybridCommandLoader.php new file mode 100644 index 00000000000..44ca0aa6462 --- /dev/null +++ b/src/Cli/HybridCommandLoader.php @@ -0,0 +1,75 @@ +deAlias($name); + if (!$this->has($name)) { + throw new CommandNotFoundException("Can't find command $name"); + } + /** @var HybridCommand $commandClass */ + $commandClass = $this->getAllowedCommands()[$name]; + $hybridCommand = $commandClass::create(); + // Use the name that was passed into the method instead of relying on the hybrid command name + // because we need the full namespace. + return HybridCommandCliWrapper::create($hybridCommand, $name); + } + + public function has(string $name): bool + { + $commands = $this->getAllowedCommands(); + if (array_key_exists($name, $commands)) { + return true; + } + return array_key_exists($this->deAlias($name), $commands); + } + + public function getNames(): array + { + return array_keys($this->getAllowedCommands()); + } + + /** + * Get the array of HybridCommand objects this loader is responsible for. + * Do not filter canRunInCli(). + * + * @return array Associative array of commands. + * The key is the full namespaced name, e.g. 'dev:build:cleanup' + */ + abstract protected function getCommands(): array; + + /** + * Get only the commands that are allowed to be run in CLI. + */ + private function getAllowedCommands(): array + { + if (empty($this->commands)) { + $commands = $this->getCommands(); + foreach ($commands as $name => $class) { + if (!$class::canRunInCli()) { + unset($commands[$name]); + } + } + $this->commands = $commands; + } + return $this->commands; + } + + private function deAlias(string $name): string + { + return str_replace('/', ':', $name); + } +} diff --git a/src/Cli/InjectorCommandLoader.php b/src/Cli/InjectorCommandLoader.php new file mode 100644 index 00000000000..3bcb1a5f1ee --- /dev/null +++ b/src/Cli/InjectorCommandLoader.php @@ -0,0 +1,32 @@ +setCommandLoader(new ArrayCommandLoader([ + new InjectorCommandLoader(), + new DevCommandLoader(), + new DevTaskLoader(), + ])); + } + + public function getVersion(): string + { + return VersionProvider::singleton()->getVersion(); + } + + public function run(?InputInterface $input = null, ?OutputInterface $output = null): int + { + $input ??= new ArgvInput(); + + $skipDatabase = $input->hasParameterOption('--no-database', true); + if ($skipDatabase) { + DB::set_conn(new NullDatabase()); + } + + // Instantiate the kernel + // TODO: Replace with a single CliKernel implementation + // or add a `skipDatabase` argument to the CoreKernel constructor + $this->kernel = $skipDatabase + ? new DatabaselessKernel(BASE_PATH) + : new CoreKernel(BASE_PATH); + + try { + $flush = $input->hasParameterOption('--flush', true); + $this->kernel->boot($flush); + /** @var EventDispatcherInterface $dispatcher */ + $dispatcher = Injector::inst()->get(EventDispatcherInterface::class . '.sake'); + $this->setDispatcher($dispatcher); + return parent::run($input, $output); + } finally { + $this->kernel->shutdown(); + } + } + + protected function configureIO(InputInterface $input, OutputInterface $output): void + { + // TODO convert arg-style paramaters to flags + // e.g. `ddev dev:build flush=1` should be read as `ddev dev:build --flush` + // DO NOT convert anything that isn't explicitly an InputOption in the given InputDefinition + // DO NOT leave them in as incidental args (e.g. should not becomd `ddev dev:build --flush flush=1`) + parent::configureIO($input, $output); + } + + protected function getDefaultInputDefinition(): InputDefinition + { + $definition = parent::getDefaultInputDefinition(); + $definition->addOptions([ + new InputOption('no-database', null, InputOption::VALUE_NONE, 'Run the command without connecting to the database'), + new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'), + ]); + return $definition; + } +} diff --git a/src/Core/Injector/Injector.php b/src/Core/Injector/Injector.php index afb909a5b84..a0deace9635 100644 --- a/src/Core/Injector/Injector.php +++ b/src/Core/Injector/Injector.php @@ -972,7 +972,7 @@ public function unregisterObjects($types) * @param bool $asSingleton If set to false a new instance will be returned. * If true a singleton will be returned unless the spec is type=prototype' * @param array $constructorArgs Args to pass in to the constructor. Note: Ignored for singletons - * @return T|mixed Instance of the specified object + * @return T Instance of the specified object */ public function get($name, $asSingleton = true, $constructorArgs = []) { diff --git a/src/Dev/BuildTask.php b/src/Dev/BuildTask.php index 9b2659c53f0..26d69551c08 100644 --- a/src/Dev/BuildTask.php +++ b/src/Dev/BuildTask.php @@ -2,97 +2,62 @@ namespace SilverStripe\Dev; -use SilverStripe\Control\HTTPRequest; -use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Extensible; use SilverStripe\Core\Injector\Injectable; +use SilverStripe\Dev\HybridExecution\Command\HybridCommand; /** - * Interface for a generic build task. Does not support dependencies. This will simply - * run a chunk of code when called. - * - * To disable the task (in the case of potentially destructive updates or deletes), declare - * the $Disabled property on the subclass. + * A task that can be run either from the CLI or via an HTTP request. + * This is often used for post-deployment tasks, e.g. migrating data to fit a new schema. */ -abstract class BuildTask +abstract class BuildTask extends HybridCommand { use Injectable; use Configurable; use Extensible; - public function __construct() - { - } - /** - * Set a custom url segment (to follow dev/tasks/) - * - * @config - * @var string + * Shown in the overview on the {@link TaskRunner} + * HTML or CLI interface. Should be short and concise. + * Do not use HTML markup. */ - private static $segment = null; + protected string $title; /** - * Make this non-nullable and change this to `bool` in CMS6 with a value of `true` - * @var bool|null + * Whether the task is allowed to be run or not. + * This property overrides `can_run_in_cli` and `can_run_in_browser` if set to false. */ - private static ?bool $is_enabled = null; + private static bool $is_enabled = true; /** - * @var bool $enabled If set to FALSE, keep it from showing in the list - * and from being executable through URL or CLI. - * @deprecated - remove in CMS 6 and rely on $is_enabled instead + * Describe the implications the task has, and the changes it makes. + * Do not use HTML markup. */ - protected $enabled = true; + protected static string $description = 'No description available'; - /** - * @var string $title Shown in the overview on the {@link TaskRunner} - * HTML or CLI interface. Should be short and concise, no HTML allowed. - */ - protected $title; + private static string|array|null $permissions_for_browser_execution = [ + 'ADMIN', + 'anyone_with_dev_admin_permissions' => 'ALL_DEV_ADMIN', + 'anyone_with_task_permissions' => 'BUILDTASK_CAN_RUN', + ]; - /** - * @var string $description Describe the implications the task has, - * and the changes it makes. Accepts HTML formatting. - */ - protected $description = 'No description available'; - - /** - * Implement this method in the task subclass to - * execute via the TaskRunner - * - * @param HTTPRequest $request - * @return void - */ - abstract public function run($request); - - /** - * @return bool - */ - public function isEnabled() + public function __construct() { - $isEnabled = $this->config()->get('is_enabled'); + } - if ($isEnabled === null) { - return $this->enabled; - } - return $isEnabled; + public function isEnabled(): bool + { + return $this->config()->get('is_enabled'); } - /** - * @return string - */ - public function getTitle() + public function getTitle(): string { - return $this->title ?: static::class; + return $this->title ?? static::class; } - /** - * @return string HTML formatted description - */ - public function getDescription() + public static function getName(): string { - return $this->description; + return parent::getName() ?: str_replace('\\', '-', static::class); } } diff --git a/src/Dev/DevBuildController.php b/src/Dev/DevBuildController.php deleted file mode 100644 index 6dc791d4294..00000000000 --- a/src/Dev/DevBuildController.php +++ /dev/null @@ -1,83 +0,0 @@ - 'build' - ]; - - private static $allowed_actions = [ - 'build' - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_BUILD', - ]; - - protected function init(): void - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure($this); - } - } - - public function build(HTTPRequest $request): HTTPResponse - { - if (Director::is_cli()) { - $da = DatabaseAdmin::create(); - return $da->handleRequest($request); - } else { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("Environment Builder", Director::absoluteBaseURL()); - echo "
"; - - $da = DatabaseAdmin::create(); - $response = $da->handleRequest($request); - - echo "
"; - echo $renderer->renderFooter(); - - return $response; - } - } - - public function canInit(): bool - { - return ( - Director::isDev() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tasks" from CLI. - || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) - || Permission::check(static::config()->get('init_permissions')) - ); - } - - public function providePermissions(): array - { - return [ - 'CAN_DEV_BUILD' => [ - 'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'), - 'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'), - 'category' => DevelopmentAdmin::permissionsCategory(), - 'sort' => 100 - ], - ]; - } -} diff --git a/src/Dev/DevConfigController.php b/src/Dev/DevConfigController.php deleted file mode 100644 index 03c53281056..00000000000 --- a/src/Dev/DevConfigController.php +++ /dev/null @@ -1,199 +0,0 @@ - 'audit', - '' => 'index' - ]; - - /** - * @var array - */ - private static $allowed_actions = [ - 'index', - 'audit', - ]; - - private static $init_permissions = [ - 'ADMIN', - 'ALL_DEV_ADMIN', - 'CAN_DEV_CONFIG', - ]; - - protected function init(): void - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure($this); - } - } - - /** - * Note: config() method is already defined, so let's just use index() - * - * @return string|HTTPResponse - */ - public function index() - { - $body = ''; - $subtitle = "Config manifest"; - - if (Director::is_cli()) { - $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? '')); - $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE); - } else { - $renderer = DebugView::create(); - $body .= $renderer->renderHeader(); - $body .= $renderer->renderInfo("Configuration", Director::absoluteBaseURL()); - $body .= "
"; - $body .= sprintf("

%s

", $subtitle); - $body .= "
";
-            $body .= Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE);
-            $body .= "
"; - $body .= "
"; - $body .= $renderer->renderFooter(); - } - - return $this->getResponse()->setBody($body); - } - - /** - * Output the extraneous config properties which are defined in .yaml but not in a corresponding class - * - * @return string|HTTPResponse - */ - public function audit() - { - $body = ''; - $missing = []; - $subtitle = "Missing Config property definitions"; - - foreach ($this->array_keys_recursive(Config::inst()->getAll(), 2) as $className => $props) { - $props = array_keys($props ?? []); - - if (!count($props ?? [])) { - // We can skip this entry - continue; - } - - if ($className == strtolower(Injector::class)) { - // We don't want to check the injector config - continue; - } - - foreach ($props as $prop) { - $defined = false; - // Check ancestry (private properties don't inherit natively) - foreach (ClassInfo::ancestry($className) as $cn) { - if (property_exists($cn, $prop ?? '')) { - $defined = true; - break; - } - } - - if ($defined) { - // No need to record this property - continue; - } - - $missing[] = sprintf("%s::$%s\n", $className, $prop); - } - } - - $output = count($missing ?? []) - ? implode("\n", $missing) - : "All configured properties are defined\n"; - - if (Director::is_cli()) { - $body .= sprintf("\n%s\n\n", strtoupper($subtitle ?? '')); - $body .= $output; - } else { - $renderer = DebugView::create(); - $body .= $renderer->renderHeader(); - $body .= $renderer->renderInfo( - "Configuration", - Director::absoluteBaseURL(), - "Config properties that are not defined (or inherited) by their respective classes" - ); - $body .= "
"; - $body .= sprintf("

%s

", $subtitle); - $body .= sprintf("
%s
", $output); - $body .= "
"; - $body .= $renderer->renderFooter(); - } - - return $this->getResponse()->setBody($body); - } - - public function canInit(): bool - { - return ( - Director::isDev() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tasks" from CLI. - || (Director::is_cli() && DevelopmentAdmin::config()->get('allow_all_cli')) - || Permission::check(static::config()->get('init_permissions')) - ); - } - - public function providePermissions(): array - { - return [ - 'CAN_DEV_CONFIG' => [ - 'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'), - 'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'), - 'category' => DevelopmentAdmin::permissionsCategory(), - 'sort' => 100 - ], - ]; - } - - /** - * Returns all the keys of a multi-dimensional array while maintining any nested structure - * - * @param array $array - * @param int $maxdepth - * @param int $depth - * @param array $arrayKeys - * @return array - */ - private function array_keys_recursive($array, $maxdepth = 20, $depth = 0, $arrayKeys = []) - { - if ($depth < $maxdepth) { - $depth++; - $keys = array_keys($array ?? []); - - foreach ($keys as $key) { - if (!is_array($array[$key])) { - continue; - } - - $arrayKeys[$key] = $this->array_keys_recursive($array[$key], $maxdepth, $depth); - } - } - - return $arrayKeys; - } -} diff --git a/src/Dev/DevConfirmationController.php b/src/Dev/DevConfirmationController.php index 2a64b4b4c22..7cf2e05ce34 100644 --- a/src/Dev/DevConfirmationController.php +++ b/src/Dev/DevConfirmationController.php @@ -3,7 +3,6 @@ namespace SilverStripe\Dev; use SilverStripe\Control\Director; -use SilverStripe\ORM\DatabaseAdmin; use SilverStripe\Security\Confirmation; /** diff --git a/src/Dev/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php index 752ae82dd64..a1bae25be6b 100644 --- a/src/Dev/DevelopmentAdmin.php +++ b/src/Dev/DevelopmentAdmin.php @@ -2,7 +2,7 @@ namespace SilverStripe\Dev; -use Exception; +use LogicException; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; @@ -10,8 +10,9 @@ use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Deprecation; -use SilverStripe\ORM\DatabaseAdmin; +use SilverStripe\Dev\HybridExecution\Command\HybridCommand; +use SilverStripe\Dev\HybridExecution\HttpRequestInput; +use SilverStripe\Dev\HybridExecution\HybridOutput; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\Security; @@ -20,47 +21,61 @@ /** * Base class for development tools. * - * Configured in framework/_config/dev.yml, with the config key registeredControllers being - * used to generate the list of links for /dev. + * Configured via the `commands` and `controllers` configuration properties */ class DevelopmentAdmin extends Controller implements PermissionProvider { - private static $url_handlers = [ '' => 'index', - 'build/defaults' => 'buildDefaults', 'generatesecuretoken' => 'generatesecuretoken', - '$Action' => 'runRegisteredController', + '$Action' => 'runRegisteredAction', ]; private static $allowed_actions = [ 'index', - 'buildDefaults', - 'runRegisteredController', + 'runRegisteredAction', 'generatesecuretoken', ]; /** - * Controllers for dev admin views + * Commands for dev admin views. + * + * Register any HybridCommand classes that you want to be under the `/dev/*` HTTP + * route and in the `dev:*` CLI namespace. + * + * Any namespaced commands will be nested under the `dev:*` CLI namespace, e.g + * `dev:my-namespace:command-two` + * + * Namespaces are also converted to URL segments for HTTP requests, e.g + * `dev/my-namspace/command-two` * * e.g [ - * 'urlsegment' => [ - * 'controller' => 'SilverStripe\Dev\DevelopmentAdmin', - * 'links' => [ - * 'urlsegment' => 'description', - * ... - * ] - * ] + * 'command-one' => 'App\HybridExecution\CommandOne', + * 'my-namespace:command-two' => 'App\HybridExecution\MyNamespace\CommandTwo', * ] + */ + private static array $commands = []; + + /** + * Controllers for dev admin views. * - * @var array + * This is for HTTP-only controllers routed under `/dev/*` which + * cannot be managed via CLI (e.g. an interactive GraphQL IDE). + * For most purposes, register a hybrid command under $commands instead. + * + * e.g [ + * 'urlsegment' => [ + * 'class' => 'App\Dev\MyHttpOnlyController', + * 'description' => 'See a list of build tasks to run', + * ], + * ] */ - private static $registered_controllers = []; + private static array $controllers = []; /** * Assume that CLI equals admin permissions * If set to false, normal permission model will apply even in CLI mode - * Applies to all development admin tasks (E.g. TaskRunner, DatabaseAdmin) + * Applies to all development admin tasks (E.g. TaskRunner, DevBuild) * * @config * @var bool @@ -82,7 +97,7 @@ protected function init() if (static::config()->get('deny_non_cli') && !Director::is_cli()) { return $this->httpError(404); } - + if (!$this->canViewAll() && empty($this->getLinks())) { Security::permissionFailure($this); return; @@ -96,154 +111,213 @@ protected function init() } } + /** + * Renders the main /dev menu in the browser + */ public function index() { + $renderer = DebugView::create(); + echo $renderer->renderHeader(); + echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()); + $base = Director::baseURL(); + + echo '
    '; + $evenOdd = "odd"; $links = $this->getLinks(); - // Web mode - if (!Director::is_cli()) { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL()); - $base = Director::baseURL(); - - echo '
      '; - $evenOdd = "odd"; - foreach ($links as $action => $description) { - echo "
    • /dev/$action:" - . " $description
    • \n"; - $evenOdd = ($evenOdd == "odd") ? "even" : "odd"; - } + foreach ($links as $path => $info) { + $class = $info['class']; + $description = $info['description'] ?? $class::singleton()->getDescription(); + echo "
    • /$path:" + . " $description
    • \n"; + $evenOdd = ($evenOdd == "odd") ? "even" : "odd"; + } - echo $renderer->renderFooter(); + echo $renderer->renderFooter(); + } - // CLI mode - } else { - echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n"; - echo "You can execute any of the following commands:\n\n"; - foreach ($links as $action => $description) { - echo " sake dev/$action: $description\n"; + /** + * Run the command, or hand execution to the controller. + * Note this method is for execution from the web only. CLI takes a different path. + */ + public function runRegisteredAction(HTTPRequest $request) + { + $fullPath = $request->getURL(); + $links = $this->getLinks(); + $class = null; + + // If full path directly matches, use that class. + if (isset($links[$fullPath])) { + $class = $links[$fullPath]['class']; + if (is_a($class, HybridCommand::class, true)) { + // Tell the request we've matched the full URL + $request->shift($request->remaining()); } - echo "\n\n"; } - } - public function runRegisteredController(HTTPRequest $request) - { - $controllerClass = null; + // The full path doesn't directly match any registered command or controller. + // Look for a controller that can handle the request. We reject commands at this stage. + // The full path will be for an action on the controller and may include nested actions, + // so we need to check all urlsegment sections within the request URL. + if (!$class) { + $parts = explode('/', $fullPath); + array_pop($parts); + while (count($parts) > 0) { + $newPath = implode('/', $parts); + // Don't check dev itself - that's the controller we're currently in. + if ($newPath === 'dev') { + break; + } + // Check for a controller that matches this partial path. + $class = $links[$newPath]['class'] ?? null; + if ($class !== null && is_a($class, Controller::class, true)) { + break; + } + array_pop($parts); + } + } - $baseUrlPart = $request->param('Action'); - $reg = Config::inst()->get(static::class, 'registered_controllers'); - if (isset($reg[$baseUrlPart])) { - $controllerClass = $reg[$baseUrlPart]['controller']; + if (!$class) { + $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action'); + $this->httpError(404, $msg); } - if ($controllerClass && class_exists($controllerClass ?? '')) { - return $controllerClass::create(); + // Hand execution to the controller + if (is_a($class, Controller::class, true)) { + return $class::create(); } - $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action'); - if (Director::is_cli()) { - // in CLI we cant use httpError because of a bug with stuff being in the output already, see DevAdminControllerTest - throw new Exception($msg); - } else { - $this->httpError(404, $msg); + /** @var HybridCommand $command */ + $command = $class::create(); + $input = HttpRequestInput::create($request, $command->getOptions()); + $output = HybridOutput::create(HybridOutput::CONTEXT_HTTP, $input->getVerbosity(), true); + $renderer = DebugView::create(); + + // Output header etc + $headerOutput = [ + $renderer->renderHeader(), + $renderer->renderInfo( + $command->getTitle(), + Director::absoluteBaseURL() + ), + '
      ', + ]; + if (ClassInfo::hasMethod($command, 'getSubtitle')) { + $headerOutput[] = "

      {$command->getSubtitle()}

      "; } - } + $output->writeForContext( + HybridOutput::CONTEXT_HTTP, + $headerOutput, + options: HybridOutput::OUTPUT_RAW + ); - /* - * Internal methods - */ + // Run command + $command->run($input, $output); + + // Output footer etc + $output->writeForContext( + HybridOutput::CONTEXT_HTTP, + [ + '
      ', + $renderer->renderFooter(), + ], + options: HybridOutput::OUTPUT_RAW + ); + } /** - * @deprecated 5.2.0 use getLinks() instead to include permission checks - * @return array of url => description + * Get all registered HybridCommands */ - protected static function get_links() + public function getCommands(): array { - Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks'); - $links = []; + $commands = []; + foreach (Config::inst()->get(static::class, 'commands') as $name => $class) { + // Allow unsetting a command via YAML + if ($class === null) { + continue; + } + // Check that the class exists and is a HybridCommand + if (!ClassInfo::exists($class)) { + throw new LogicException("Class '$class' doesn't exist"); + } + if (!is_a($class, HybridCommand::class, true)) { + throw new LogicException("Class '$class' must be a subclass of " . HybridCommand::class); + } - $reg = Config::inst()->get(static::class, 'registered_controllers'); - foreach ($reg as $registeredController) { - if (isset($registeredController['links'])) { - foreach ($registeredController['links'] as $url => $desc) { - $links[$url] = $desc; - } + // Check that the command name (without namespace) matches what the command thinks its name is + $parts = explode(':', $name); + $nameLeaf = end($parts); + $realName = $class::getName(); + if ($nameLeaf !== $realName) { + throw new LogicException( + "Class '$class' has a command_name of '$realName'." + . " DevelopmentAdmin.commands configuration thinks it should be '$nameLeaf'." + ); } + + // Add to list of commands + $commands['dev:' . $name] = $class; } - return $links; + return $commands; } - protected function getLinks(): array + /** + * Get a map of paths to classes for all registered commands and controllers for this context. + * Should usually only be used for browser-based execution but accounts for unusual usages of CLI as well. + */ + public function getLinks(): array { + // TODO make sure canViewAll is correct for CLI vs non-CLI $canViewAll = $this->canViewAll(); $links = []; - $reg = Config::inst()->get(static::class, 'registered_controllers'); - foreach ($reg as $registeredController) { - if (isset($registeredController['links'])) { - if (!ClassInfo::exists($registeredController['controller'])) { - continue; - } - if (!$canViewAll) { - // Check access to controller - $controllerSingleton = Injector::inst()->get($registeredController['controller']); - if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) { + foreach ($this->getCommands() as $name => $commandClass) { + // Check command can run in current context + if (!$canViewAll) { + if (Director::is_cli()) { + if (!$commandClass::canRunInCli()) { continue; } - } - - foreach ($registeredController['links'] as $url => $desc) { - $links[$url] = $desc; + } elseif (!$commandClass::canRunInBrowser()) { + continue; } } + + $path = str_replace(':', '/', $name); + $links[$path] = ['class' => $commandClass]; } - return $links; - } - protected function getRegisteredController($baseUrlPart) - { - $reg = Config::inst()->get(static::class, 'registered_controllers'); + foreach (static::config()->get('controllers') as $urlSegment => $info) { + // Allow unsetting a controller via YAML + if ($info === null) { + continue; + } + $controllerClass = $info['class']; + // Check that the class exists and is a HybridCommand + if (!ClassInfo::exists($controllerClass)) { + throw new LogicException("Class '$controllerClass' doesn't exist"); + } + if (!is_a($controllerClass, Controller::class, true)) { + throw new LogicException("Class '$controllerClass' must be a subclass of " . Controller::class); + } + + if (!$canViewAll) { + // Check access to controller + $controllerSingleton = Injector::inst()->get($controllerClass); + if (!$controllerSingleton->hasMethod('canInit') || !$controllerSingleton->canInit()) { + continue; + } + } - if (isset($reg[$baseUrlPart])) { - $controllerClass = $reg[$baseUrlPart]['controller']; - return $controllerClass; + $links['dev/' . $urlSegment] = $info; } - return null; + return $links; } - /* * Unregistered (hidden) actions */ - /** - * Build the default data, calling requireDefaultRecords on all - * DataObject classes - * Should match the $url_handlers rule: - * 'build/defaults' => 'buildDefaults', - */ - public function buildDefaults() - { - $da = DatabaseAdmin::create(); - - $renderer = null; - if (!Director::is_cli()) { - $renderer = DebugView::create(); - echo $renderer->renderHeader(); - echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL()); - echo "
      "; - } - - $da->buildDefaults(); - - if (!Director::is_cli()) { - echo "
      "; - echo $renderer->renderFooter(); - } - } - /** * Generate a secure token which can be used as a crypto key. * Returns the token and suggests PHP configuration to set it. @@ -287,7 +361,8 @@ public static function permissionsCategory(): string protected function canViewAll(): bool { - // Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957) + // TODO resolve this + // Special case for dev/build: Defer permission checks to DevBuild->init() (see #4957) $requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0) && (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false); diff --git a/src/Dev/HybridExecution/AnsiToHtmlConverter.php b/src/Dev/HybridExecution/AnsiToHtmlConverter.php new file mode 100644 index 00000000000..432a553a3a8 --- /dev/null +++ b/src/Dev/HybridExecution/AnsiToHtmlConverter.php @@ -0,0 +1,129 @@ += 50400 ? ENT_QUOTES | ENT_SUBSTITUTE : ENT_QUOTES, $this->charset); + + // carriage return + $text = preg_replace('#^.*\r(?!\n)#m', '', $text); + + $tokens = $this->tokenize($text); + + // a backspace remove the previous character but only from a text token + foreach ($tokens as $i => $token) { + if ('backspace' == $token[0]) { + $j = $i; + while (--$j >= 0) { + if ('text' == $tokens[$j][0] && strlen($tokens[$j][1]) > 0) { + $tokens[$j][1] = substr($tokens[$j][1], 0, -1); + + break; + } + } + } + } + + $html = ''; + foreach ($tokens as $token) { + if ('text' == $token[0]) { + $html .= $token[1]; + } elseif ('color' == $token[0]) { + $html .= $this->convertAnsiToColor($token[1]); + } + } + + // These lines commented out from the parent class implementation. + // We don't want this opinionated default colouring - it doesn't appear in the ANSI format so it doesn't belong in the output. + // if ($this->inlineStyles) { + // $html = sprintf('%s', $this->inlineColors['black'], $this->inlineColors['white'], $html); + // } else { + // $html = sprintf('%s', $html); + // } + + // remove empty span + $html = preg_replace('#]*>#', '', $html); + + return $html; + } + + protected function convertAnsiToColor($ansi) + { + // Set $bg and $fg to null so we don't have a default opinionated colouring + $bg = null; + $fg = null; + $style = []; + $classes = []; + if ('0' != $ansi && '' != $ansi) { + $options = explode(';', $ansi); + + foreach ($options as $option) { + if ($option >= 30 && $option < 38) { + $fg = $option - 30; + } elseif ($option >= 40 && $option < 48) { + $bg = $option - 40; + } elseif (39 == $option) { + $fg = 7; + } elseif (49 == $option) { + $bg = 0; + } + } + + // options: bold => 1, underscore => 4, blink => 5, reverse => 7, conceal => 8 + if (in_array(1, $options)) { + $style[] = 'font-weight: bold'; + $classes[] = 'ansi_bold'; + } + + if (in_array(4, $options)) { + $style[] = 'text-decoration: underline'; + $classes[] = 'ansi_underline'; + } + + if (in_array(7, $options)) { + $tmp = $fg; + $fg = $bg; + $bg = $tmp; + } + } + + // Biggest changes start here and go to the end of the method. + // We're explicitly only setting the styling that was included in the ANSI formatting. The original applies + // default colours regardless. + if ($bg !== null) { + $style[] = sprintf('background-color: %s', $this->inlineColors[$this->colorNames[$bg]]); + $classes[] = sprintf('ansi_color_bg_%s', $this->colorNames[$bg]); + } + if ($fg !== null) { + $style[] = sprintf('color: %s', $this->inlineColors[$this->colorNames[$fg]]); + $classes[] = sprintf('ansi_color_fg_%s', $this->colorNames[$fg]); + } + + if ($this->inlineStyles && !empty($style)) { + return sprintf('', implode('; ', $style)); + } + if (!$this->inlineStyles && !empty($classes)) { + return sprintf('', implode('; ', $classes)); + } + + // Because of the way the parent class is implemented, we need to stop the old span and start a new one + // even if we don't have any styling to apply. + return ''; + } +} diff --git a/src/Dev/HybridExecution/Command/DevBuild.php b/src/Dev/HybridExecution/Command/DevBuild.php new file mode 100644 index 00000000000..2b0c59eca5b --- /dev/null +++ b/src/Dev/HybridExecution/Command/DevBuild.php @@ -0,0 +1,339 @@ + 'App\\NewNamespace\\MyClass' + */ + private static array $classname_value_remapping = []; + + /** + * Config setting to enabled/disable the display of record counts on the dev/build output + */ + private static bool $show_record_counts = true; + + public function getTitle(): string + { + return 'Environment Builder'; + } + + public function getSubtitle(): string + { + $conn = DB::get_conn(); + // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database") + $dbType = substr(get_class($conn), 0, -8); + $dbVersion = $conn->getVersion(); + $databaseName = $conn->getSelectedDatabase(); + return sprintf('Building database %s using %s %s', $databaseName, $dbType, $dbVersion); + } + + public function run(InputInterface $input, HybridOutput $output): int + { + // The default time limit of 30 seconds is normally not enough + Environment::increaseTimeLimitTo(600); + + // If this code is being run without a flush, we need to at least flush the class manifest + if (!$input->getOption('flush')) { + ClassLoader::inst()->getManifest()->regenerate(false); + } + + $this->doBuild($output, !$input->getOption('no-populate')); + return Command::SUCCESS; + } + + + /** + * Updates the database schema, creating tables & fields as necessary. + * + * @param bool $populate Populate the database, as well as setting up its schema + */ + public function doBuild(HybridOutput $output, bool $populate = true, bool $testMode = false): void + { + $this->extend('onBeforeBuild', $output, $populate, $testMode); + + if ($output->isQuiet()) { + DB::quiet(); + } + + // Set up the initial database + if (!DB::is_active()) { + $output->writeln(['Creating database', '']); + + // Load parameters from existing configuration + $databaseConfig = DB::getConfig(); + if (empty($databaseConfig)) { + throw new BadMethodCallException("No database configuration available"); + } + + // Check database name is given + if (empty($databaseConfig['database'])) { + throw new BadMethodCallException( + "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME" + ); + } + $database = $databaseConfig['database']; + + // Establish connection + unset($databaseConfig['database']); + DB::connect($databaseConfig); + + // Create database + DB::create_database($database); + } + + // Build the database. Most of the hard work is handled by DataObject + $dataClasses = ClassInfo::subclassesFor(DataObject::class); + array_shift($dataClasses); + + $output->writeln(['Creating database tables', '']); + $output->startList(HybridOutput::LIST_UNORDERED); + + $showRecordCounts = (bool) static::config()->get('show_record_counts'); + + // Initiate schema update + $dbSchema = DB::get_schema(); + $tableBuilder = TableBuilder::singleton(); + $tableBuilder->buildTables($dbSchema, $dataClasses, [], $output->isQuiet(), $testMode, $showRecordCounts); + ClassInfo::reset_db_cache(); + + $output->stopList(); + + if ($populate) { + $output->writeln(['Creating database records', '']); + $output->startList(HybridOutput::LIST_UNORDERED); + + // Remap obsolete class names + $this->migrateClassNames(); + + // Require all default records + foreach ($dataClasses as $dataClass) { + // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness + // Test_ indicates that it's the data class is part of testing system + if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) { + $output->writeListItem($dataClass); + DataObject::singleton($dataClass)->requireDefaultRecords(); + } + } + + $output->stopList(); + } + + touch(static::getLastGeneratedFilePath()); + + $output->writeln(['Database build completed!']); + + foreach ($dataClasses as $dataClass) { + DataObject::singleton($dataClass)->onAfterBuild(); + } + + ClassInfo::reset_db_cache(); + + $this->extend('onAfterBuild', $output, $populate, $testMode); + } + + public function getOptions(): array + { + return [ + new InputOption( + 'no-populate', + null, + InputOption::VALUE_NONE, + 'Don\'t run `requireDefaultRecords()` on the models when building.' + . 'This will build the table but not insert any records' + ), + ]; + } + + public function providePermissions(): array + { + return [ + 'CAN_DEV_BUILD' => [ + 'name' => _t(__CLASS__ . '.CAN_DEV_BUILD_DESCRIPTION', 'Can execute /dev/build'), + 'help' => _t(__CLASS__ . '.CAN_DEV_BUILD_HELP', 'Can execute the build command (/dev/build).'), + 'category' => DevelopmentAdmin::permissionsCategory(), + 'sort' => 100 + ], + ]; + } + + /** + * Given a base data class, a field name and a mapping of class replacements, look for obsolete + * values in the $dataClass's $fieldName column and replace it with $mapping + * + * @param string $dataClass The data class to look up + * @param string $fieldName The field name to look in for obsolete class names + * @param string[] $mapping Map of old to new classnames + */ + protected function updateLegacyClassNameField(string $dataClass, string $fieldName, array $mapping): void + { + $schema = DataObject::getSchema(); + // Check first to ensure that the class has the specified field to update + if (!$schema->databaseField($dataClass, $fieldName, false)) { + return; + } + + // Load a list of any records that have obsolete class names + $table = $schema->tableName($dataClass); + $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column(); + + // Get all invalid classes for this field + $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? [])); + if (!$invalidClasses) { + return; + } + + $numberClasses = count($invalidClasses ?? []); + DB::alteration_message( + "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types", + 'obsolete' + ); + + // Build case assignment based on all intersected legacy classnames + $cases = []; + $params = []; + foreach ($invalidClasses as $invalidClass) { + $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?"; + $params[] = $invalidClass; + $params[] = $mapping[$invalidClass]; + } + + foreach ($this->getClassTables($dataClass) as $table) { + $casesSQL = implode(' ', $cases); + $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END"; + DB::prepared_query($sql, $params); + } + } + + /** + * Get tables to update for this class + */ + protected function getClassTables(string $dataClass): iterable + { + $schema = DataObject::getSchema(); + $table = $schema->tableName($dataClass); + + // Base table + yield $table; + + // Remap versioned table class name values as well + /** @var Versioned|DataObject $dataClass */ + $dataClass = DataObject::singleton($dataClass); + if ($dataClass->hasExtension(Versioned::class)) { + if ($dataClass->hasStages()) { + yield "{$table}_Live"; + } + yield "{$table}_Versions"; + } + } + + /** + * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes + * `ClassName` fields as well as polymorphic class name fields. + * + * @return array[] + */ + protected function getClassNameRemappingFields(): array + { + $dataClasses = ClassInfo::getValidSubClasses(DataObject::class); + $schema = DataObject::getSchema(); + $remapping = []; + + foreach ($dataClasses as $className) { + $fieldSpecs = $schema->fieldSpecs($className); + foreach ($fieldSpecs as $fieldName => $fieldSpec) { + if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) { + $remapping[$className][] = $fieldName; + } + } + } + + return $remapping; + } + + /** + * Migrate all class names + */ + protected function migrateClassNames(): void + { + $remappingConfig = static::config()->get('classname_value_remapping'); + $remappingFields = $this->getClassNameRemappingFields(); + foreach ($remappingFields as $className => $fieldNames) { + foreach ($fieldNames as $fieldName) { + $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig); + } + } + } + + /** + * Returns the timestamp of the time that the database was last built + * or an empty string if we can't find that information. + */ + public static function lastBuilt(): string + { + $file = static::getLastGeneratedFilePath(); + if (file_exists($file)) { + return filemtime($file); + } + return ''; + } + + /** + * Check whether this command can be run in CLI via sake + */ + public static function canRunInCli(): bool + { + return parent::canRunInCli() || !Security::database_is_ready(); + } + + /** + * Check whether this command can be run in the browser via a web request + */ + public static function canRunInBrowser(): bool + { + return parent::canRunInBrowser() || !Security::database_is_ready(); + } + + private static function getLastGeneratedFilePath(): string + { + return TEMP_PATH + . DIRECTORY_SEPARATOR + . 'database-last-generated-' + . str_replace(['\\', '/', ':'], '.', Director::baseFolder()); + } +} diff --git a/src/Dev/HybridExecution/Command/DevBuildCleanup.php b/src/Dev/HybridExecution/Command/DevBuildCleanup.php new file mode 100644 index 00000000000..83041578b01 --- /dev/null +++ b/src/Dev/HybridExecution/Command/DevBuildCleanup.php @@ -0,0 +1,88 @@ +startList(HybridOutput::LIST_UNORDERED); + foreach ($baseClasses as $baseClass) { + // Get data classes + $baseTable = $schema->baseDataTable($baseClass); + $subclasses = ClassInfo::subclassesFor($baseClass); + unset($subclasses[0]); + foreach ($subclasses as $k => $subclass) { + if (!DataObject::getSchema()->classHasTable($subclass)) { + unset($subclasses[$k]); + } + } + + if ($subclasses) { + $records = DB::query("SELECT * FROM \"$baseTable\""); + + + foreach ($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); + $recordExists[$subclass] = + DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn(); + } + + foreach ($records as $record) { + foreach ($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); + $id = $record['ID']; + if (($record['ClassName'] != $subclass) + && (!is_subclass_of($record['ClassName'], $subclass ?? '')) + && isset($recordExists[$subclass][$id]) + ) { + $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?"; + $output->writeListItem("$sql [{$id}]"); + DB::prepared_query($sql, [$id]); + $countDeleted++; + } + } + } + } + } + $output->stopList(); + $output->writeln("Deleted {$countDeleted} rows"); + return Command::SUCCESS; + } +} diff --git a/src/Dev/HybridExecution/Command/DevBuildDefaults.php b/src/Dev/HybridExecution/Command/DevBuildDefaults.php new file mode 100644 index 00000000000..f0b883c7427 --- /dev/null +++ b/src/Dev/HybridExecution/Command/DevBuildDefaults.php @@ -0,0 +1,47 @@ +startList(HybridOutput::LIST_UNORDERED); + foreach ($dataClasses as $dataClass) { + singleton($dataClass)->requireDefaultRecords(); + $output->writeListItem("Defaults loaded for $dataClass"); + } + $output->stopList(); + + return Command::SUCCESS; + } +} diff --git a/src/Dev/HybridExecution/Command/DevConfig.php b/src/Dev/HybridExecution/Command/DevConfig.php new file mode 100644 index 00000000000..182534b0005 --- /dev/null +++ b/src/Dev/HybridExecution/Command/DevConfig.php @@ -0,0 +1,64 @@ +writeForContext( + HybridOutput::CONTEXT_HTTP, + '
      ',
      +            options: HybridOutput::OUTPUT_RAW
      +        );
      +
      +        $output->write(Yaml::dump(Config::inst()->getAll(), 99, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE));
      +
      +        $output->writeForContext(
      +            HybridOutput::CONTEXT_HTTP,
      +            '
      ', + options: HybridOutput::OUTPUT_RAW + ); + return Command::SUCCESS; + } + + public function providePermissions(): array + { + return [ + 'CAN_DEV_CONFIG' => [ + 'name' => _t(__CLASS__ . '.CAN_DEV_CONFIG_DESCRIPTION', 'Can view /dev/config'), + 'help' => _t(__CLASS__ . '.CAN_DEV_CONFIG_HELP', 'Can view all application configuration (/dev/config).'), + 'category' => DevelopmentAdmin::permissionsCategory(), + 'sort' => 100 + ], + ]; + } +} diff --git a/src/Dev/HybridExecution/Command/DevConfigAudit.php b/src/Dev/HybridExecution/Command/DevConfigAudit.php new file mode 100644 index 00000000000..68c30dc2e34 --- /dev/null +++ b/src/Dev/HybridExecution/Command/DevConfigAudit.php @@ -0,0 +1,90 @@ +getAll(), 2) as $className => $props) { + $props = array_keys($props ?? []); + + if (!count($props ?? [])) { + // We can skip this entry + continue; + } + + if ($className == strtolower(Injector::class)) { + // We don't want to check the injector config + continue; + } + + foreach ($props as $prop) { + $defined = false; + // Check ancestry (private properties don't inherit natively) + foreach (ClassInfo::ancestry($className) as $cn) { + if (property_exists($cn, $prop ?? '')) { + $defined = true; + break; + } + } + + if ($defined) { + // No need to record this property + continue; + } + + $missing[] = sprintf("%s::$%s\n", $className, $prop); + } + } + + $body = count($missing ?? []) + ? implode("\n", $missing) + : "All configured properties are defined\n"; + + $output->writeForContext( + HybridOutput::CONTEXT_HTTP, + '
      ',
      +            options: HybridOutput::OUTPUT_RAW
      +        );
      +        $output->write($body);
      +        $output->writeForContext(
      +            HybridOutput::CONTEXT_HTTP,
      +            '
      ', + options: HybridOutput::OUTPUT_RAW + ); + + return Command::SUCCESS; + } +} diff --git a/src/Dev/HybridExecution/Command/HybridCommand.php b/src/Dev/HybridExecution/Command/HybridCommand.php new file mode 100644 index 00000000000..12467813c66 --- /dev/null +++ b/src/Dev/HybridExecution/Command/HybridCommand.php @@ -0,0 +1,146 @@ + + */ + public function getOptions(): array + { + return []; + } + + /** + * Check whether this command can be run in CLI via sake + */ + public static function canRunInCli(): bool + { + static::checkPrerequisites(); + return Director::isDev() + || static::config()->get('can_run_in_cli') + || DevelopmentAdmin::config()->get('allow_all_cli'); + } + + /** + * Check whether this command can be run in the browser via a web request + */ + public static function canRunInBrowser(): bool + { + static::checkPrerequisites(); + // Can always run in browser in dev mode + if (Director::isDev()) { + return true; + } + // If allowed to be run in browser, check user has correct permissions + return static::config()->get('can_run_in_browser') + && Permission::check(static::config()->get('permissions_for_browser_execution')); + } + + private static function checkPrerequisites(): void + { + $mandatoryConfig = [ + 'permissions_for_browser_execution', + ]; + foreach ($mandatoryConfig as $config) { + if (!static::config()->get($config)) { + throw new RuntimeException($config . ' configuration property needs to be set.'); + } + } + $mandatoryMethods = [ + 'getName' => 'commandName', + 'getDescription' => 'description', + ]; + foreach ($mandatoryMethods as $getter => $property) { + if (!static::$getter()) { + throw new RuntimeException($property . ' property needs to be set.'); + } + } + } +} diff --git a/src/Dev/HybridExecution/HtmlOutputFormatter.php b/src/Dev/HybridExecution/HtmlOutputFormatter.php new file mode 100644 index 00000000000..07eb277313f --- /dev/null +++ b/src/Dev/HybridExecution/HtmlOutputFormatter.php @@ -0,0 +1,58 @@ +ansiFormatter = $formatter; + $this->ansiConverter = AnsiToHtmlConverter::create(); + } + + public function setDecorated(bool $decorated): void + { + $this->ansiFormatter->setDecorated($decorated); + } + + public function isDecorated(): bool + { + return $this->ansiFormatter->isDecorated(); + } + + public function setStyle(string $name, OutputFormatterStyleInterface $style): void + { + $this->ansiFormatter->setStyle($name, $style); + } + + public function hasStyle(string $name): bool + { + return $this->ansiFormatter->hasStyle($name); + } + + public function getStyle(string $name): OutputFormatterStyleInterface + { + return $this->ansiFormatter->getStyle($name); + } + + public function format(?string $message): ?string + { + $formatted = $this->ansiFormatter->format($message); + if ($this->isDecorated()) { + return $this->ansiConverter->convert($formatted); + } + return $formatted; + } +} diff --git a/src/Dev/HybridExecution/HttpRequestInput.php b/src/Dev/HybridExecution/HttpRequestInput.php new file mode 100644 index 00000000000..1174b14e483 --- /dev/null +++ b/src/Dev/HybridExecution/HttpRequestInput.php @@ -0,0 +1,103 @@ + $commandOptions Any options that apply for the command itself. + * Do not include global options (e.g. flush) - they are added explicitly in the constructor. + */ + public function __construct(HTTPRequest $request, array $commandOptions = []) + { + $definition = new InputDefinition([ + // Also add global options that are applicable for HTTP requests + new InputOption('quiet', 'q', InputOption::VALUE_NONE, 'Do not output any message'), + new InputOption('verbose', 'v|vv|vvv', InputOption::VALUE_NONE, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug'), + new InputOption('flush', 'f', InputOption::VALUE_NONE, 'Flush the cache before running the command'), + ...$commandOptions + ]); + $optionValues = $this->getOptionValuesFromRequest($request, $definition); + parent::__construct($optionValues, $definition); + } + + /** + * Get the verbosity that should be used based on the request vars. + * This is used to set the verbosity for HybridOutput. + */ + public function getVerbosity(): int + { + if ($this->getOption('quiet')) { + return OutputInterface::VERBOSITY_QUIET; + } + $verbose = $this->getOption('verbose'); + if ($verbose === '1') { + return OutputInterface::VERBOSITY_VERBOSE; + } + if ($verbose === '2') { + return OutputInterface::VERBOSITY_VERY_VERBOSE; + } + if ($verbose === '3') { + return OutputInterface::VERBOSITY_DEBUG; + } + return OutputInterface::VERBOSITY_NORMAL; + } + + private function getOptionValuesFromRequest(HTTPRequest $request, InputDefinition $definition): array + { + $options = []; + foreach ($definition->getOptions() as $option) { + // We'll check for the long name and all shortcuts. + // Note the `--` and `-` prefixes are already stripped at this point. + $candidateParams = [$option->getName()]; + $shortcutString = $option->getShortcut(); + if ($shortcutString !== null) { + $shortcuts = explode('|', $shortcutString); + foreach ($shortcuts as $shortcut) { + $candidateParams[] = $shortcut; + } + } + // Get a value if there is one + $value = null; + foreach ($candidateParams as $candidateParam) { + $value = $request->requestVar($candidateParam); + if ($value !== null) { + // Verbosity shortcuts are handled differently to everything else + $value = match ($candidateParam) { + 'v' => '1', + 'vv' => '2', + 'vvv' => '3', + default => $value + }; + break; + } + } + // We need to prefix with `--` so the superclass knows it's an + // option rather than an argument. + if ($value !== null || $option->acceptValue()) { + // If the option doesn't accept a value, determine the correct boolean state for it. + // If we weren't able to determine if the value's boolean-ness, default to truthy=true + // because that's what you'd end up with with `if ($request->requestVar('myVar'))` + if (!$option->acceptValue()) { + $value = filter_var($value, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true; + } + $options['--' . $option->getName()] = $value; + } + } + return $options; + } +} diff --git a/src/Dev/HybridExecution/HybridOutput.php b/src/Dev/HybridExecution/HybridOutput.php new file mode 100644 index 00000000000..1c051739ff2 --- /dev/null +++ b/src/Dev/HybridExecution/HybridOutput.php @@ -0,0 +1,220 @@ +context = $context; + switch ($context) { + case HybridOutput::CONTEXT_CLI: + $this->consoleOutput = $consoleOutput ?? Injector::inst()->create(ConsoleOutput::class); + // Give console output a debug verbosity - that way it'll output everything we tell it to. + // Actual verbosity is handled by HybridOutput's parent Output class. + $this->consoleOutput->setVerbosity(Output::VERBOSITY_DEBUG); + $this->setFormatter(new OutputFormatter()); + break; + case HybridOutput::CONTEXT_HTTP: + if ($consoleOutput) { + throw new InvalidArgumentException('Cannot use consoleOutput in HTTP context'); + } + $this->setFormatter(HtmlOutputFormatter::create(new OutputFormatter())); + break; + default: + throw new InvalidArgumentException("Unexpected context - got '$context'."); + } + // Intentionally don't call parent constructor, because it doesn't use the setter methods. + $this->setDecorated($decorated); + $this->setVerbosity($verbosity); + } + + /** + * Writes messages to the ouput - but only if we're in the given context. + * + * @param string $listType One of the LIST_* consts, e.g. HybridOutput::LIST_UNORDERED + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + */ + public function writeForContext( + string $context, + string|iterable $messages, + bool $newline = false, + int $options = Output::OUTPUT_NORMAL + ): void { + if ($this->context === $context) { + $this->write($messages, $newline, $options); + } + } + + /** + * Start a list. + * In HTTP context this will write the opening `
        ` or `
          ` tag. + * In CLI context this will provide information for how to render list items. + * + * Call writeListItem() to add items to the list, then call stopList() when you're done. + * + * @param string $listType One of the LIST_* consts, e.g. HybridOutput::LIST_UNORDERED + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * 0 is considered the same as self::OUTPUT_NORMAL | self::VERBOSITY_NORMAL + */ + public function startList(string $listType = HybridOutput::LIST_UNORDERED, int $options = Output::OUTPUT_NORMAL): void + { + $this->listTypeStack[] = ['type' => $listType, 'options' => $options]; + if ($this->context === HybridOutput::CONTEXT_HTTP) { + $this->write("<{$listType}>", options: $this->forceRawOutput($options)); + } + } + + /** + * Stop a list. + * In HTTP context this will write the closing `
      ` or `` tag. + * In CLI context this will mark the list as closed (useful when nesting lists) + */ + public function stopList(): void + { + if (empty($this->listTypeStack)) { + throw new LogicException('No list to close.'); + } + $info = array_pop($this->listTypeStack); + if ($this->context === HybridOutput::CONTEXT_HTTP) { + $this->write("", options: $this->forceRawOutput($info['options'])); + } + } + + /** + * Writes messages formatted as a list. + * Make sure to call startList() before writing list items, and call stopList() when you're done. + * + * @param int $options A bitmask of options (one of the OUTPUT or VERBOSITY constants), + * by default this will inherit the options used to start the list. + */ + public function writeListItem(string|iterable $items, ?int $options = null): void + { + if (empty($this->listTypeStack)) { + throw new LogicException('No lists started. Call startList() first.'); + } + if (is_string($items)) { + $items = [$items]; + } + $method = "writeListItem{$this->context}"; + $this->$method($items, $options); + } + + public function write(string|iterable $messages, bool $newline = false, int $options = Output::OUTPUT_NORMAL): void + { + if ($this->context === HybridOutput::CONTEXT_CLI) { + $this->consoleOutput->write($messages, $newline, $options); + return; + } + // This will do some pre-processing before handing off to doWrite() + parent::write($messages, $newline, $options); + } + + public function setDecorated(bool $decorated): void + { + parent::setDecorated($decorated); + $this->consoleOutput?->setDecorated($decorated); + } + + public function setFormatter(OutputFormatterInterface $formatter): void + { + parent::setFormatter($formatter); + $this->consoleOutput?->setFormatter($formatter); + } + + protected function doWrite(string $message, bool $newline): void + { + if ($this->context !== HybridOutput::CONTEXT_HTTP) { + throw new BadMethodCallException('Something went wrong - doWrite should only be called in HTTP context'); + } + echo $message . ($newline ? "
      \n" : ''); + } + + private function writeListItemHttp(iterable $items, ?int $options): void + { + if ($options === null) { + $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)]; + $options = $listInfo['options']; + } + foreach ($items as $item) { + $this->write('
    • ', options: $this->forceRawOutput($options)); + $this->write($item, options: $options); + $this->write('
    • ', options: $this->forceRawOutput($options)); + } + } + + private function writeListItemCli(iterable $items, ?int $options): void + { + $listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)]; + $listType = $listInfo['type']; + if ($options === null) { + $options = $listInfo['options']; + } + foreach ($items as $i => $item) { + switch ($listType) { + case HybridOutput::LIST_UNORDERED: + $bullet = '*'; + break; + case HybridOutput::LIST_UNORDERED: + // Start at 1 + $bullet = $i + 1 . '.'; + break; + default: + throw new InvalidArgumentException("Unexpected list type - got '$listType'."); + } + $indent = str_repeat(' ', count($this->listTypeStack)); + $this->writeln("{$indent}{$bullet} {$item}", $options); + } + } + + private function getVerbosityOption(int $options): int + { + $verbosities = Output::VERBOSITY_QUIET | Output::VERBOSITY_NORMAL | Output::VERBOSITY_VERBOSE | Output::VERBOSITY_VERY_VERBOSE | Output::VERBOSITY_DEBUG; + return $verbosities & $options ?: Output::VERBOSITY_NORMAL; + } + + private function forceRawOutput(int $options): int + { + return $this->getVerbosityOption($options) | Output::OUTPUT_RAW; + } +} diff --git a/src/Dev/MigrationTask.php b/src/Dev/MigrationTask.php index 58981ffdaab..f65f0684100 100644 --- a/src/Dev/MigrationTask.php +++ b/src/Dev/MigrationTask.php @@ -2,77 +2,49 @@ namespace SilverStripe\Dev; +use SilverStripe\Dev\HybridExecution\HybridOutput; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + /** * A migration task is a build task that is reversible. * - * Creating Migration Tasks - * * To create your own migration task, you need to define your own subclass of MigrationTask - * and implement the following methods - * - * app/src/MyMigrationTask.php - * - * - * class MyMigrationTask extends MigrationTask { - * - * private static $segment = 'MyMigrationTask'; // segment in the dev/tasks/ namespace for URL access - * protected $title = "My Database Migrations"; // title of the script - * protected $description = "My Description"; // description of what it does - * - * public function run($request) { - * if ($request->getVar('Direction') == 'down') { - * $this->down(); - * } else { - * $this->up(); - * } - * } - * - * public function up() { - * // do something when going from old -> new - * } - * - * public function down() { - * // do something when going from new -> old - * } - * } - * - * - * Running Migration Tasks - * You can find all tasks under the dev/tasks/ namespace. - * To run the above script you would need to run the following and note - Either the site has to be - * in [devmode](debugging) or you need to add ?isDev=1 to the URL. - * - * - * // url to visit if in dev mode. - * https://www.yoursite.com/dev/tasks/MyMigrationTask - * - * // url to visit if you are in live mode but need to run this - * https://www.yoursite.com/dev/tasks/MyMigrationTask?isDev=1 - * + * and implement the abstract methods. */ abstract class MigrationTask extends BuildTask { - - private static $segment = 'MigrationTask'; - - protected $title = "Database Migrations"; - - protected $description = "Provide atomic database changes (subclass this and implement yourself)"; - - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - if ($request->param('Direction') == 'down') { + if ($input->getOption('direction') === 'down') { $this->down(); } else { $this->up(); } + return Command::SUCCESS; } - public function up() - { - } + /** + * Migrate from old to new + */ + abstract public function up(); + + /** + * Revert the migration (new to old) + */ + abstract public function down(); - public function down() + public function getOptions(): array { + return [ + new InputOption( + 'direction', + null, + InputOption::VALUE_REQUIRED, + '"up" if migrating from old to new, "down" to revert a migration', + suggestedValues: ['up', 'down'], + ), + ]; } } diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php index b54a58e5554..2499b9f6f0b 100644 --- a/src/Dev/SapphireTest.php +++ b/src/Dev/SapphireTest.php @@ -38,7 +38,7 @@ use SilverStripe\Security\Permission; use SilverStripe\Security\Security; use SilverStripe\View\SSViewer; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\Transport\NullTransport; diff --git a/src/Dev/State/ExtensionTestState.php b/src/Dev/State/ExtensionTestState.php index 0cf274367a7..088673eeb72 100644 --- a/src/Dev/State/ExtensionTestState.php +++ b/src/Dev/State/ExtensionTestState.php @@ -88,7 +88,7 @@ public function setUpOnce($class) } // clear singletons, they're caching old extension info - // which is used in DatabaseAdmin->doBuild() + // which is used in DevBuild->doBuild() Injector::inst()->unregisterObjects([ DataObject::class, Extension::class diff --git a/src/Dev/TaskRunner.php b/src/Dev/TaskRunner.php index 216d6b31327..10ca9f1242e 100644 --- a/src/Dev/TaskRunner.php +++ b/src/Dev/TaskRunner.php @@ -11,7 +11,10 @@ use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Manifest\ModuleResourceLoader; +use SilverStripe\Dev\HybridExecution\HttpRequestInput; +use SilverStripe\Dev\HybridExecution\HybridOutput; use SilverStripe\ORM\ArrayList; +use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; use SilverStripe\Security\Security; @@ -20,7 +23,6 @@ class TaskRunner extends Controller implements PermissionProvider { - use Configurable; private static $url_handlers = [ @@ -123,7 +125,20 @@ public function runTask($request) return; } - $inst->run($request); + $input = HttpRequestInput::create($request, $inst->getOptions()); + $output = HybridOutput::create(HybridOutput::CONTEXT_HTTP, $input->getVerbosity(), true); + $before = DBDatetime::now(); + + $exitCode = $inst->run($input, $output); + + $after = DBDatetime::now(); + $message = 'Task completed successfully'; + if ($exitCode !== 0) { + $message = 'Task failed'; + } + $timeTaken = DBDatetime::getTimeBetween($before, $after); + $message .= " in $timeTaken"; + $output->writeln(['', "{$message}"]); return; } } @@ -132,9 +147,24 @@ public function runTask($request) } /** - * @return array Array of associative arrays for each task (Keys: 'class', 'title', 'description') + * Get an associative array of task names to classes for all enabled BuildTasks + */ + public function getTaskList(): array + { + $taskList = []; + $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false); + foreach ($taskClasses as $taskClass) { + if ($this->taskEnabled($taskClass)) { + $taskList[$taskClass::getName()] = $taskClass; + } + } + return $taskList; + } + + /** + * Get the class names of all build tasks */ - protected function getTasks() + protected function getTasks(): array { $availableTasks = []; @@ -158,18 +188,6 @@ protected function getTasks() return $availableTasks; } - protected function getTaskList(): array - { - $taskClasses = ClassInfo::subclassesFor(BuildTask::class, false); - foreach ($taskClasses as $index => $task) { - if (!$this->taskEnabled($task)) { - unset($taskClasses[$index]); - } - } - - return $taskClasses; - } - /** * @param string $class * @return boolean @@ -236,7 +254,7 @@ public function canInit(): bool } return count($this->getTaskList()) > 0; } - + public function providePermissions(): array { return [ diff --git a/src/Dev/Tasks/CleanupTestDatabasesTask.php b/src/Dev/Tasks/CleanupTestDatabasesTask.php index 77b0c397b8d..d26c27f0311 100644 --- a/src/Dev/Tasks/CleanupTestDatabasesTask.php +++ b/src/Dev/Tasks/CleanupTestDatabasesTask.php @@ -2,11 +2,11 @@ namespace SilverStripe\Dev\Tasks; -use SilverStripe\Control\Director; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; use SilverStripe\ORM\Connect\TempDatabase; -use SilverStripe\Security\Permission; -use SilverStripe\Security\Security; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; /** * Cleans up leftover databases from aborted test executions (starting with ss_tmpdb) @@ -14,27 +14,20 @@ */ class CleanupTestDatabasesTask extends BuildTask { + protected static string $commandName = 'CleanupTestDatabasesTask'; - private static $segment = 'CleanupTestDatabasesTask'; + protected string $title = 'Deletes all temporary test databases'; - protected $title = 'Deletes all temporary test databases'; + protected static string $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)'; - protected $description = 'Cleans up leftover databases from aborted test executions (starting with ss_tmpdb)'; + private static string|array|null $permissions_for_browser_execution = [ + 'anyone_with_dev_admin_permissions' => null, + 'anyone_with_task_permissions' => null, + ]; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - if (!$this->canView()) { - $response = Security::permissionFailure(); - if ($response) { - $response->output(); - } - die; - } TempDatabase::create()->deleteAll(); - } - - public function canView(): bool - { - return Permission::check('ADMIN') || Director::is_cli(); + return Command::SUCCESS; } } diff --git a/src/Dev/Tasks/i18nTextCollectorTask.php b/src/Dev/Tasks/i18nTextCollectorTask.php index 8ecd4b279b7..d5064e832ca 100644 --- a/src/Dev/Tasks/i18nTextCollectorTask.php +++ b/src/Dev/Tasks/i18nTextCollectorTask.php @@ -2,83 +2,71 @@ namespace SilverStripe\Dev\Tasks; -use SilverStripe\Control\HTTPRequest; use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Debug; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; use SilverStripe\i18n\TextCollection\i18nTextCollector; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; /** * Collects i18n strings + * + * It will search for existent modules that use the i18n feature, parse the _t() calls + * and write the resultant files in the lang folder of each module. */ class i18nTextCollectorTask extends BuildTask { + protected static string $commandName = 'i18nTextCollectorTask'; - private static $segment = 'i18nTextCollectorTask'; + protected string $title = "i18n Textcollector Task"; - protected $title = "i18n Textcollector Task"; + protected static string $description = 'Traverses through files in order to collect the ' + . '"entity master tables" stored in each module.'; - protected $description = " - Traverses through files in order to collect the 'entity master tables' - stored in each module. - - Parameters: - - locale: Sets default locale - - writer: Custom writer class (defaults to i18nTextCollector_Writer_RailsYaml) - - module: One or more modules to limit collection (comma-separated) - - merge: Merge new strings with existing ones already defined in language files (default: TRUE) - "; - - /** - * This is the main method to build the master string tables with the original strings. - * It will search for existent modules that use the i18n feature, parse the _t() calls - * and write the resultant files in the lang folder of each module. - * - * @uses DataObject::collectI18nStatics() - * - * @param HTTPRequest $request - */ - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { Environment::increaseTimeLimitTo(); - $collector = i18nTextCollector::create($request->getVar('locale')); + $collector = i18nTextCollector::create($input->getOption('locale')); - $merge = $this->getIsMerge($request); + $merge = $this->getIsMerge($input); // Custom writer - $writerName = $request->getVar('writer'); + $writerName = $input->getOption('writer'); if ($writerName) { $writer = Injector::inst()->get($writerName); $collector->setWriter($writer); } // Get restrictions - $restrictModules = ($request->getVar('module')) - ? explode(',', $request->getVar('module')) + $restrictModules = ($input->getOption('module')) + ? explode(',', $input->getOption('module')) : null; $collector->run($restrictModules, $merge); - Debug::message(__CLASS__ . " completed!", false); + return Command::SUCCESS; } /** * Check if we should merge - * - * @param HTTPRequest $request - * @return bool */ - protected function getIsMerge($request) + protected function getIsMerge(InputInterface $input): bool { - $merge = $request->getVar('merge'); - - // Default to true if not given - if (!isset($merge)) { - return true; - } - + $merge = $input->getOption('merge'); // merge=0 or merge=false will disable merge return !in_array($merge, ['0', 'false']); } + + public function getOptions(): array + { + return [ + new InputOption('locale', null, InputOption::VALUE_REQUIRED, 'Sets default locale'), + new InputOption('writer', null, InputOption::VALUE_REQUIRED, 'Custom writer class', 'i18nTextCollector_Writer_RailsYaml'), + new InputOption('module', null, InputOption::VALUE_REQUIRED, 'One or more modules to limit collection (comma-separated)'), + new InputOption('merge', null, InputOption::VALUE_NEGATABLE, 'Merge new strings with existing ones already defined in language files', true), + ]; + } } diff --git a/src/Dev/TestMailer.php b/src/Dev/TestMailer.php index 7bfe228acc1..cf83baa5911 100644 --- a/src/Dev/TestMailer.php +++ b/src/Dev/TestMailer.php @@ -5,7 +5,7 @@ use Exception; use InvalidArgumentException; use SilverStripe\Control\Email\Email; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Mailer\Envelope; use Symfony\Component\Mailer\Event\MessageEvent; use Symfony\Component\Mailer\MailerInterface; diff --git a/src/Dev/Validation/DatabaseAdminExtension.php b/src/Dev/Validation/DevBuildExtension.php similarity index 55% rename from src/Dev/Validation/DatabaseAdminExtension.php rename to src/Dev/Validation/DevBuildExtension.php index fbcf5ffc244..a9950e0ce18 100644 --- a/src/Dev/Validation/DatabaseAdminExtension.php +++ b/src/Dev/Validation/DevBuildExtension.php @@ -4,24 +4,21 @@ use ReflectionException; use SilverStripe\Core\Extension; -use SilverStripe\ORM\DatabaseAdmin; +use SilverStripe\Dev\HybridExecution\Command\DevBuild; /** * Hook up static validation to the deb/build process * - * @extends Extension + * @extends Extension */ -class DatabaseAdminExtension extends Extension +class DevBuildExtension extends Extension { /** - * Extension point in @see DatabaseAdmin::doBuild() + * Extension point in @see DevBuild::doBuild() * - * @param bool $quiet - * @param bool $populate - * @param bool $testMode * @throws ReflectionException */ - public function onAfterBuild(bool $quiet, bool $populate, bool $testMode): void + public function onAfterBuild(): void { $service = RelationValidationService::singleton(); diff --git a/src/Logging/HTTPOutputHandler.php b/src/Logging/ErrorOutputHandler.php similarity index 86% rename from src/Logging/HTTPOutputHandler.php rename to src/Logging/ErrorOutputHandler.php index b6d922342ba..1813e7259f0 100644 --- a/src/Logging/HTTPOutputHandler.php +++ b/src/Logging/ErrorOutputHandler.php @@ -11,12 +11,11 @@ use SilverStripe\Dev\Deprecation; /** - * Output the error to the browser, with the given HTTP status code. - * We recommend that you use a formatter that generates HTML with this. + * Output the error to either the browser or the terminal, depending on + * the context we're running in. */ -class HTTPOutputHandler extends AbstractProcessingHandler +class ErrorOutputHandler extends AbstractProcessingHandler { - /** * @var string */ @@ -47,7 +46,7 @@ public function getContentType() * Default text/html * * @param string $contentType - * @return HTTPOutputHandler Return $this to allow chainable calls + * @return ErrorOutputHandler Return $this to allow chainable calls */ public function setContentType($contentType) { @@ -82,7 +81,7 @@ public function setStatusCode($statusCode) * Set a formatter to use if Director::is_cli() is true * * @param FormatterInterface $cliFormatter - * @return HTTPOutputHandler Return $this to allow chainable calls + * @return ErrorOutputHandler Return $this to allow chainable calls */ public function setCLIFormatter(FormatterInterface $cliFormatter) { @@ -146,7 +145,7 @@ protected function shouldShowError(int $errorCode): bool // or our deprecations when the relevant shouldShow method returns true return $errorCode !== E_USER_DEPRECATED || !Deprecation::isTriggeringError() - || ($this->isCli() ? Deprecation::shouldShowForCli() : Deprecation::shouldShowForHttp()); + || (Director::is_cli() ? Deprecation::shouldShowForCli() : Deprecation::shouldShowForHttp()); } /** @@ -165,6 +164,11 @@ protected function write(LogRecord $record): void } } + if (Director::is_cli()) { + echo $record['formatted']; + return; + } + if (Controller::has_curr()) { $response = Controller::curr()->getResponse(); } else { @@ -183,12 +187,4 @@ protected function write(LogRecord $record): void $response->setBody($record['formatted']); $response->output(); } - - /** - * This method is required and must be protected for unit testing, since we can't mock static or private methods - */ - protected function isCli(): bool - { - return Director::is_cli(); - } } diff --git a/src/ORM/ArrayLib.php b/src/ORM/ArrayLib.php index 371d0f6dfca..b5dbc8ed684 100644 --- a/src/ORM/ArrayLib.php +++ b/src/ORM/ArrayLib.php @@ -85,6 +85,32 @@ public static function array_values_recursive($array) return ArrayLib::flatten($array, false); } + + /** + * Returns all the keys of a multi-dimensional array while maintining any nested structure + */ + public static function arrayKeysRecursive( + array $array, + int $maxdepth = 20, + int $depth = 0, + array $arrayKeys = [] + ): array { + if ($depth < $maxdepth) { + $depth++; + $keys = array_keys($array ?? []); + + foreach ($keys as $key) { + if (!is_array($array[$key])) { + continue; + } + + $arrayKeys[$key] = static::arrayKeysRecursive($array[$key], $maxdepth, $depth); + } + } + + return $arrayKeys; + } + /** * Filter an array by keys (useful for only allowing certain form-input to * be saved). diff --git a/src/ORM/Connect/TempDatabase.php b/src/ORM/Connect/TempDatabase.php index ced43d8469d..af620a3fa2b 100644 --- a/src/ORM/Connect/TempDatabase.php +++ b/src/ORM/Connect/TempDatabase.php @@ -233,7 +233,7 @@ protected function rebuildTables($extraDataObjects = []) { DataObject::reset(); - // clear singletons, they're caching old extension info which is used in DatabaseAdmin->doBuild() + // clear singletons, they're caching old extension info which is used in DevBuild->doBuild() Injector::inst()->unregisterObjects(DataObject::class); $dataClasses = ClassInfo::subclassesFor(DataObject::class); diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 43414fc301a..28e80a8f2e9 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -3807,7 +3807,7 @@ public function requireDefaultRecords() * Invoked after every database build is complete (including after table creation and * default record population). * - * See {@link DatabaseAdmin::doBuild()} for context. + * See {@link DevBuild::doBuild()} for context. */ public function onAfterBuild() { diff --git a/src/ORM/DatabaseAdmin.php b/src/ORM/DatabaseAdmin.php deleted file mode 100644 index 8d08336fc22..00000000000 --- a/src/ORM/DatabaseAdmin.php +++ /dev/null @@ -1,538 +0,0 @@ - 'SilverStripe\\Assets\\File', - 'Image' => 'SilverStripe\\Assets\\Image', - 'Folder' => 'SilverStripe\\Assets\\Folder', - 'Group' => 'SilverStripe\\Security\\Group', - 'LoginAttempt' => 'SilverStripe\\Security\\LoginAttempt', - 'Member' => 'SilverStripe\\Security\\Member', - 'MemberPassword' => 'SilverStripe\\Security\\MemberPassword', - 'Permission' => 'SilverStripe\\Security\\Permission', - 'PermissionRole' => 'SilverStripe\\Security\\PermissionRole', - 'PermissionRoleCode' => 'SilverStripe\\Security\\PermissionRoleCode', - 'RememberLoginHash' => 'SilverStripe\\Security\\RememberLoginHash', - ]; - - /** - * Config setting to enabled/disable the display of record counts on the dev/build output - */ - private static $show_record_counts = true; - - protected function init() - { - parent::init(); - - if (!$this->canInit()) { - Security::permissionFailure( - $this, - "This page is secured and you need elevated permissions to access it. " . - "Enter your credentials below and we will send you right along." - ); - } - } - - /** - * Get the data classes, grouped by their root class - * - * @return array Array of data classes, grouped by their root class - */ - public function groupedDataClasses() - { - // Get all root data objects - $allClasses = get_declared_classes(); - $rootClasses = []; - foreach ($allClasses as $class) { - if (get_parent_class($class ?? '') == DataObject::class) { - $rootClasses[$class] = []; - } - } - - // Assign every other data object one of those - foreach ($allClasses as $class) { - if (!isset($rootClasses[$class]) && is_subclass_of($class, DataObject::class)) { - foreach ($rootClasses as $rootClass => $dummy) { - if (is_subclass_of($class, $rootClass ?? '')) { - $rootClasses[$rootClass][] = $class; - break; - } - } - } - } - return $rootClasses; - } - - - /** - * When we're called as /dev/build, that's actually the index. Do the same - * as /dev/build/build. - */ - public function index() - { - return $this->build(); - } - - /** - * Updates the database schema, creating tables & fields as necessary. - */ - public function build() - { - // The default time limit of 30 seconds is normally not enough - Environment::increaseTimeLimitTo(600); - - // If this code is being run outside of a dev/build or without a ?flush query string param, - // the class manifest hasn't been flushed, so do it here - $request = $this->getRequest(); - if (!array_key_exists('flush', $request->getVars() ?? []) && strpos($request->getURL() ?? '', 'dev/build') !== 0) { - ClassLoader::inst()->getManifest()->regenerate(false); - } - - $url = $this->getReturnURL(); - if ($url) { - echo "

      Setting up the database; you will be returned to your site shortly....

      "; - $this->doBuild(true); - echo "

      Done!

      "; - $this->redirect($url); - } else { - $quiet = $this->request->requestVar('quiet') !== null; - $fromInstaller = $this->request->requestVar('from_installer') !== null; - $populate = $this->request->requestVar('dont_populate') === null; - $this->doBuild($quiet || $fromInstaller, $populate); - } - } - - /** - * Gets the url to return to after build - * - * @return string|null - */ - protected function getReturnURL() - { - $url = $this->request->getVar('returnURL'); - - // Check that this url is a site url - if (empty($url) || !Director::is_site_url($url)) { - return null; - } - - // Convert to absolute URL - return Director::absoluteURL((string) $url, true); - } - - /** - * Build the default data, calling requireDefaultRecords on all - * DataObject classes - */ - public function buildDefaults() - { - $dataClasses = ClassInfo::subclassesFor(DataObject::class); - array_shift($dataClasses); - - if (!Director::is_cli()) { - echo "
        "; - } - - foreach ($dataClasses as $dataClass) { - singleton($dataClass)->requireDefaultRecords(); - if (Director::is_cli()) { - echo "Defaults loaded for $dataClass\n"; - } else { - echo "
      • Defaults loaded for $dataClass
      • \n"; - } - } - - if (!Director::is_cli()) { - echo "
      "; - } - } - - /** - * Returns the timestamp of the time that the database was last built - * - * @return string Returns the timestamp of the time that the database was - * last built - */ - public static function lastBuilt() - { - $file = TEMP_PATH - . DIRECTORY_SEPARATOR - . 'database-last-generated-' - . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? ''); - - if (file_exists($file ?? '')) { - return filemtime($file ?? ''); - } - return null; - } - - - /** - * Updates the database schema, creating tables & fields as necessary. - * - * @param boolean $quiet Don't show messages - * @param boolean $populate Populate the database, as well as setting up its schema - * @param bool $testMode - */ - public function doBuild($quiet = false, $populate = true, $testMode = false) - { - $this->extend('onBeforeBuild', $quiet, $populate, $testMode); - - if ($quiet) { - DB::quiet(); - } else { - $conn = DB::get_conn(); - // Assumes database class is like "MySQLDatabase" or "MSSQLDatabase" (suffixed with "Database") - $dbType = substr(get_class($conn), 0, -8); - $dbVersion = $conn->getVersion(); - $databaseName = $conn->getSelectedDatabase(); - - if (Director::is_cli()) { - echo sprintf("\n\nBuilding database %s using %s %s\n\n", $databaseName, $dbType, $dbVersion); - } else { - echo sprintf("

      Building database %s using %s %s

      ", $databaseName, $dbType, $dbVersion); - } - } - - // Set up the initial database - if (!DB::is_active()) { - if (!$quiet) { - echo '

      Creating database

      '; - } - - // Load parameters from existing configuration - $databaseConfig = DB::getConfig(); - if (empty($databaseConfig) && empty($_REQUEST['db'])) { - throw new BadMethodCallException("No database configuration available"); - } - $parameters = (!empty($databaseConfig)) ? $databaseConfig : $_REQUEST['db']; - - // Check database name is given - if (empty($parameters['database'])) { - throw new BadMethodCallException( - "No database name given; please give a value for SS_DATABASE_NAME or set SS_DATABASE_CHOOSE_NAME" - ); - } - $database = $parameters['database']; - - // Establish connection - unset($parameters['database']); - DB::connect($parameters); - - // Check to ensure that the re-instated SS_DATABASE_SUFFIX functionality won't unexpectedly - // rename the database. To be removed for SS5 - if ($suffix = Environment::getEnv('SS_DATABASE_SUFFIX')) { - $previousName = preg_replace("/{$suffix}$/", '', $database ?? ''); - - if (!isset($_GET['force_suffix_rename']) && DB::get_conn()->databaseExists($previousName)) { - throw new DatabaseException( - "SS_DATABASE_SUFFIX was previously broken, but has now been fixed. This will result in your " - . "database being named \"{$database}\" instead of \"{$previousName}\" from now on. If this " - . "change is intentional, please visit dev/build?force_suffix_rename=1 to continue" - ); - } - } - - // Create database - DB::create_database($database); - } - - // Build the database. Most of the hard work is handled by DataObject - $dataClasses = ClassInfo::subclassesFor(DataObject::class); - array_shift($dataClasses); - - if (!$quiet) { - if (Director::is_cli()) { - echo "\nCREATING DATABASE TABLES\n\n"; - } else { - echo "\n

      Creating database tables

        \n\n"; - } - } - - $showRecordCounts = (boolean)$this->config()->show_record_counts; - - // Initiate schema update - $dbSchema = DB::get_schema(); - $tableBuilder = TableBuilder::singleton(); - $tableBuilder->buildTables($dbSchema, $dataClasses, [], $quiet, $testMode, $showRecordCounts); - ClassInfo::reset_db_cache(); - - if (!$quiet && !Director::is_cli()) { - echo "
      "; - } - - if ($populate) { - if (!$quiet) { - if (Director::is_cli()) { - echo "\nCREATING DATABASE RECORDS\n\n"; - } else { - echo "\n

      Creating database records

        \n\n"; - } - } - - // Remap obsolete class names - $this->migrateClassNames(); - - // Require all default records - foreach ($dataClasses as $dataClass) { - // Check if class exists before trying to instantiate - this sidesteps any manifest weirdness - // Test_ indicates that it's the data class is part of testing system - if (strpos($dataClass ?? '', 'Test_') === false && class_exists($dataClass ?? '')) { - if (!$quiet) { - if (Director::is_cli()) { - echo " * $dataClass\n"; - } else { - echo "
      • $dataClass
      • \n"; - } - } - - DataObject::singleton($dataClass)->requireDefaultRecords(); - } - } - - if (!$quiet && !Director::is_cli()) { - echo "
      "; - } - } - - touch(TEMP_PATH - . DIRECTORY_SEPARATOR - . 'database-last-generated-' - . str_replace(['\\', '/', ':'], '.', Director::baseFolder() ?? '')); - - if (isset($_REQUEST['from_installer'])) { - echo "OK"; - } - - if (!$quiet) { - echo (Director::is_cli()) ? "\n Database build completed!\n\n" : "

      Database build completed!

      "; - } - - foreach ($dataClasses as $dataClass) { - DataObject::singleton($dataClass)->onAfterBuild(); - } - - ClassInfo::reset_db_cache(); - - $this->extend('onAfterBuild', $quiet, $populate, $testMode); - } - - public function canInit(): bool - { - // We allow access to this controller regardless of live-status or ADMIN permission only - // if on CLI or with the database not ready. The latter makes it less error-prone to do an - // initial schema build without requiring a default-admin login. - // Access to this controller is always allowed in "dev-mode", or of the user is ADMIN. - $allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli'); - return ( - Director::isDev() - || !Security::database_is_ready() - // We need to ensure that DevelopmentAdminTest can simulate permission failures when running - // "dev/tests" from CLI. - || (Director::is_cli() && $allowAllCLI) - || Permission::check(DevBuildController::config()->get('init_permissions')) - ); - } - - /** - * Given a base data class, a field name and a mapping of class replacements, look for obsolete - * values in the $dataClass's $fieldName column and replace it with $mapping - * - * @param string $dataClass The data class to look up - * @param string $fieldName The field name to look in for obsolete class names - * @param string[] $mapping Map of old to new classnames - */ - protected function updateLegacyClassNameField($dataClass, $fieldName, $mapping) - { - $schema = DataObject::getSchema(); - // Check first to ensure that the class has the specified field to update - if (!$schema->databaseField($dataClass, $fieldName, false)) { - return; - } - - // Load a list of any records that have obsolete class names - $table = $schema->tableName($dataClass); - $currentClassNameList = DB::query("SELECT DISTINCT(\"{$fieldName}\") FROM \"{$table}\"")->column(); - - // Get all invalid classes for this field - $invalidClasses = array_intersect($currentClassNameList ?? [], array_keys($mapping ?? [])); - if (!$invalidClasses) { - return; - } - - $numberClasses = count($invalidClasses ?? []); - DB::alteration_message( - "Correcting obsolete {$fieldName} values for {$numberClasses} outdated types", - 'obsolete' - ); - - // Build case assignment based on all intersected legacy classnames - $cases = []; - $params = []; - foreach ($invalidClasses as $invalidClass) { - $cases[] = "WHEN \"{$fieldName}\" = ? THEN ?"; - $params[] = $invalidClass; - $params[] = $mapping[$invalidClass]; - } - - foreach ($this->getClassTables($dataClass) as $table) { - $casesSQL = implode(' ', $cases); - $sql = "UPDATE \"{$table}\" SET \"{$fieldName}\" = CASE {$casesSQL} ELSE \"{$fieldName}\" END"; - DB::prepared_query($sql, $params); - } - } - - /** - * Get tables to update for this class - * - * @param string $dataClass - * @return Generator|string[] - */ - protected function getClassTables($dataClass) - { - $schema = DataObject::getSchema(); - $table = $schema->tableName($dataClass); - - // Base table - yield $table; - - // Remap versioned table class name values as well - /** @var Versioned|DataObject $dataClass */ - $dataClass = DataObject::singleton($dataClass); - if ($dataClass->hasExtension(Versioned::class)) { - if ($dataClass->hasStages()) { - yield "{$table}_Live"; - } - yield "{$table}_Versions"; - } - } - - /** - * Find all DBClassName fields on valid subclasses of DataObject that should be remapped. This includes - * `ClassName` fields as well as polymorphic class name fields. - * - * @return array[] - */ - protected function getClassNameRemappingFields() - { - $dataClasses = ClassInfo::getValidSubClasses(DataObject::class); - $schema = DataObject::getSchema(); - $remapping = []; - - foreach ($dataClasses as $className) { - $fieldSpecs = $schema->fieldSpecs($className); - foreach ($fieldSpecs as $fieldName => $fieldSpec) { - if (Injector::inst()->create($fieldSpec, 'Dummy') instanceof DBClassName) { - $remapping[$className][] = $fieldName; - } - } - } - - return $remapping; - } - - /** - * Remove invalid records from tables - that is, records that don't have - * corresponding records in their parent class tables. - */ - public function cleanup() - { - $baseClasses = []; - foreach (ClassInfo::subclassesFor(DataObject::class) as $class) { - if (get_parent_class($class ?? '') == DataObject::class) { - $baseClasses[] = $class; - } - } - - $schema = DataObject::getSchema(); - foreach ($baseClasses as $baseClass) { - // Get data classes - $baseTable = $schema->baseDataTable($baseClass); - $subclasses = ClassInfo::subclassesFor($baseClass); - unset($subclasses[0]); - foreach ($subclasses as $k => $subclass) { - if (!DataObject::getSchema()->classHasTable($subclass)) { - unset($subclasses[$k]); - } - } - - if ($subclasses) { - $records = DB::query("SELECT * FROM \"$baseTable\""); - - - foreach ($subclasses as $subclass) { - $subclassTable = $schema->tableName($subclass); - $recordExists[$subclass] = - DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn(); - } - - foreach ($records as $record) { - foreach ($subclasses as $subclass) { - $subclassTable = $schema->tableName($subclass); - $id = $record['ID']; - if (($record['ClassName'] != $subclass) - && (!is_subclass_of($record['ClassName'], $subclass ?? '')) - && isset($recordExists[$subclass][$id]) - ) { - $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?"; - echo "
    • $sql [{$id}]
    • "; - DB::prepared_query($sql, [$id]); - } - } - } - } - } - } - - /** - * Migrate all class names - */ - protected function migrateClassNames() - { - $remappingConfig = $this->config()->get('classname_value_remapping'); - $remappingFields = $this->getClassNameRemappingFields(); - foreach ($remappingFields as $className => $fieldNames) { - foreach ($fieldNames as $fieldName) { - $this->updateLegacyClassNameField($className, $fieldName, $remappingConfig); - } - } - } -} diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php index 877c707f260..cbc3b8e860f 100644 --- a/src/ORM/FieldType/DBDatetime.php +++ b/src/ORM/FieldType/DBDatetime.php @@ -2,6 +2,7 @@ namespace SilverStripe\ORM\FieldType; +use DateTime; use Exception; use IntlDateFormatter; use InvalidArgumentException; @@ -195,6 +196,69 @@ public function scaffoldFormField($title = null, $params = null) return $field; } + /** + * Get the amount of time inbetween two datetimes. + */ + public static function getTimeBetween(DBDateTime $from, DBDateTime $to): string + { + $fromRaw = new DateTime(); + $fromRaw->setTimestamp((int) $from->getTimestamp()); + $toRaw = new DateTime(); + $toRaw->setTimestamp((int) $to->getTimestamp()); + $diff = $fromRaw->diff($toRaw); + $result = []; + if ($diff->y) { + $result[] = _t( + __CLASS__ . '.nYears', + 'one year|{count} years', + ['count' => $diff->y] + ); + } + if ($diff->m) { + $result[] = _t( + __CLASS__ . '.nMonths', + 'one month|{count} months', + ['count' => $diff->m] + ); + } + if ($diff->d) { + $result[] = _t( + __CLASS__ . '.nDays', + 'one day|{count} days', + ['count' => $diff->d] + ); + } + if ($diff->h) { + $result[] = _t( + __CLASS__ . '.nHours', + 'one hour|{count} hours', + ['count' => $diff->h] + ); + } + if ($diff->i) { + $result[] = _t( + __CLASS__ . '.nMinutes', + 'one minute|{count} minutes', + ['count' => $diff->i] + ); + } + if ($diff->s) { + $result[] = _t( + __CLASS__ . '.nSeconds', + 'one second|{count} seconds', + ['count' => $diff->s] + ); + } + if (empty($result)) { + return _t( + __CLASS__ . '.nSeconds', + '{count} seconds', + ['count' => 0] + ); + } + return implode(', ', $result); + } + /** * */ diff --git a/src/Security/Security.php b/src/Security/Security.php index 214dbcb47d8..01d7291b444 100644 --- a/src/Security/Security.php +++ b/src/Security/Security.php @@ -1067,7 +1067,7 @@ public static function encrypt_password($password, $salt = null, $algorithm = nu /** * Checks the database is in a state to perform security checks. - * See {@link DatabaseAdmin->init()} for more information. + * See DevBuild permission checks for more information. * * @return bool */ diff --git a/src/i18n/TextCollection/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php index 3d3d6fa6f8f..d899246b503 100644 --- a/src/i18n/TextCollection/i18nTextCollector.php +++ b/src/i18n/TextCollection/i18nTextCollector.php @@ -6,7 +6,6 @@ use LogicException; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Config\Config; -use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\Extension; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Manifest\ClassLoader; diff --git a/tests/php/Dev/BuildTaskTest.php b/tests/php/Dev/BuildTaskTest.php index 8f8f5420949..9deaac32a3b 100644 --- a/tests/php/Dev/BuildTaskTest.php +++ b/tests/php/Dev/BuildTaskTest.php @@ -4,6 +4,8 @@ use SilverStripe\Dev\SapphireTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; +use Symfony\Component\Console\Input\InputInterface; class BuildTaskTest extends SapphireTest { @@ -19,9 +21,9 @@ public function testIsEnabled(): void $enabledTask = new class extends BuildTask { protected $enabled = true; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - // noop + return 0; } }; $this->assertTrue($enabledTask->isEnabled()); @@ -31,9 +33,9 @@ public function run($request) $disabledTask = new class extends BuildTask { protected $enabled = false; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - // noop + return 0; } }; $this->assertFalse($disabledTask->isEnabled()); diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php index eeabc78a090..21442bc0505 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_AbstractTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; +use Symfony\Component\Console\Input\InputInterface; abstract class TaskRunnerTest_AbstractTask extends BuildTask { protected $enabled = true; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php index e0748a8607b..13e6b56c103 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_DisabledTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; +use Symfony\Component\Console\Input\InputInterface; class TaskRunnerTest_DisabledTask extends BuildTask { protected $enabled = false; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php index c76bd16f0c6..2bb6997c86c 100644 --- a/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php +++ b/tests/php/Dev/TaskRunnerTest/TaskRunnerTest_EnabledTask.php @@ -3,13 +3,15 @@ namespace SilverStripe\Dev\Tests\TaskRunnerTest; use SilverStripe\Dev\BuildTask; +use SilverStripe\Dev\HybridExecution\HybridOutput; +use Symfony\Component\Console\Input\InputInterface; class TaskRunnerTest_EnabledTask extends BuildTask { protected $enabled = true; - public function run($request) + public function run(InputInterface $input, HybridOutput $output): int { - // NOOP + return 0; } } diff --git a/tests/php/Logging/HTTPOutputHandlerTest.php b/tests/php/Logging/ErrorOutputHandlerTest.php similarity index 84% rename from tests/php/Logging/HTTPOutputHandlerTest.php rename to tests/php/Logging/ErrorOutputHandlerTest.php index e72eaf95aa0..7b40f7f938a 100644 --- a/tests/php/Logging/HTTPOutputHandlerTest.php +++ b/tests/php/Logging/ErrorOutputHandlerTest.php @@ -3,17 +3,19 @@ namespace SilverStripe\Logging\Tests; use Monolog\Handler\HandlerInterface; +use ReflectionClass; use ReflectionMethod; use ReflectionProperty; use SilverStripe\Control\Director; +use SilverStripe\Core\Environment; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Deprecation; use SilverStripe\Dev\SapphireTest; use SilverStripe\Logging\DebugViewFriendlyErrorFormatter; use SilverStripe\Logging\DetailedErrorFormatter; -use SilverStripe\Logging\HTTPOutputHandler; +use SilverStripe\Logging\ErrorOutputHandler; -class HTTPOutputHandlerTest extends SapphireTest +class ErrorOutputHandlerTest extends SapphireTest { protected function setUp(): void { @@ -26,7 +28,7 @@ protected function setUp(): void public function testGetFormatter() { - $handler = new HTTPOutputHandler(); + $handler = new ErrorOutputHandler(); $detailedFormatter = new DetailedErrorFormatter(); $friendlyFormatter = new DebugViewFriendlyErrorFormatter(); @@ -47,9 +49,9 @@ public function testGetFormatter() */ public function testDevConfig() { - /** @var HTTPOutputHandler $handler */ + /** @var ErrorOutputHandler $handler */ $handler = Injector::inst()->get(HandlerInterface::class); - $this->assertInstanceOf(HTTPOutputHandler::class, $handler); + $this->assertInstanceOf(ErrorOutputHandler::class, $handler); // Test only default formatter is set, but CLI specific formatter is left out $this->assertNull($handler->getCLIFormatter()); @@ -154,7 +156,7 @@ public function testShouldShowError( bool $shouldShow, bool $expected ) { - $reflectionShouldShow = new ReflectionMethod(HTTPOutputHandler::class, 'shouldShowError'); + $reflectionShouldShow = new ReflectionMethod(ErrorOutputHandler::class, 'shouldShowError'); $reflectionShouldShow->setAccessible(true); $reflectionTriggeringError = new ReflectionProperty(Deprecation::class, 'isTriggeringError'); $reflectionTriggeringError->setAccessible(true); @@ -173,14 +175,19 @@ public function testShouldShowError( } $reflectionTriggeringError->setValue($triggeringError); - $mockHandler = $this->getMockBuilder(HTTPOutputHandler::class)->onlyMethods(['isCli'])->getMock(); - $mockHandler->method('isCli')->willReturn($isCli); - - $result = $reflectionShouldShow->invoke($mockHandler, $errorCode); - $this->assertSame($expected, $result); - - Deprecation::setShouldShowForCli($cliShouldShowOrig); - Deprecation::setShouldShowForHttp($httpShouldShowOrig); - $reflectionTriggeringError->setValue($triggeringErrorOrig); + $reflectionDirector = new ReflectionClass(Environment::class); + $origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride'); + $reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli); + try { + $handler = new ErrorOutputHandler(); + $result = $reflectionShouldShow->invoke($handler, $errorCode); + $this->assertSame($expected, $result); + + Deprecation::setShouldShowForCli($cliShouldShowOrig); + Deprecation::setShouldShowForHttp($httpShouldShowOrig); + $reflectionTriggeringError->setValue($triggeringErrorOrig); + } finally { + $reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli); + } } }