diff --git a/_config/dev.yml b/_config/dev.yml
index 4c1636bc4b5..2f088975a2e 100644
--- a/_config/dev.yml
+++ b/_config/dev.yml
@@ -2,21 +2,19 @@
Name: DevelopmentAdmin
---
SilverStripe\Dev\DevelopmentAdmin:
+ 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'
registered_controllers:
- build:
- controller: SilverStripe\Dev\DevBuildController
- links:
- build: 'Build/rebuild this environment. Call this whenever you have updated your project sources'
tasks:
controller: SilverStripe\Dev\TaskRunner
links:
tasks: 'See a list of build tasks to run'
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/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..32e2615692f
--- /dev/null
+++ b/src/Cli/DevCommandLoader.php
@@ -0,0 +1,96 @@
+init();
+ $name = $this->deAlias($name);
+ if (!$this->has($name)) {
+ throw new CommandNotFoundException("Can't find command $name");
+ }
+ /** @var HybridCommand $commandClass */
+ $commandClass = $this->commands[$name];
+ $hybridCommand = $commandClass::create();
+ // Use the name that was passed into the method instead of fetching from the hybrid command
+ // because it includes the full namespace.
+ // TODO move all this (plus getting $hybridCommand->getOptions()) into its own class to wrap hybridcommand with
+ // we'll be reusing it for dev/tasks after all.
+ $command = new Command($name);
+ $command->setAliases([$this->makeAlias($name)]);
+ $command->setDefinition(new InputDefinition($hybridCommand->getOptions()));
+ $command->setDescription($commandClass::getDescription());
+ $command->setCode(function (InputInterface $input, OutputInterface $output) use ($hybridCommand) {
+ $hybridOutput = HybridOutput::create(
+ HybridOutput::CONTEXT_CLI,
+ $output->getVerbosity(),
+ $output->isDecorated(),
+ $output
+ );
+ // TODO make the subtitle look a lil nicer
+ if (ClassInfo::hasMethod($hybridCommand, 'subTitle')) {
+ $hybridOutput->writeln([$hybridCommand->getSubtitle(), '--------']);
+ }
+ return $hybridCommand->run($input, $hybridOutput);
+ });
+ return $command;
+ }
+
+ public function has(string $name): bool
+ {
+ $this->init();
+ if (array_key_exists($name, $this->commands)) {
+ return true;
+ }
+ var_dump($name);
+ return array_key_exists($this->deAlias($name), $this->commands);
+ }
+
+ public function getNames(): array
+ {
+ $this->init();
+ return array_keys($this->commands);
+ }
+
+ private function init(): void
+ {
+ if (!empty($this->commands)) {
+ return;
+ }
+ $commands = DevelopmentAdmin::singleton()->getCommands();
+ foreach ($commands as $name => $class) {
+ if (!$class::canRunInBrowser()) {
+ unset($commands[$name]);
+ }
+ }
+ $this->commands = $commands;
+ }
+
+ private function deAlias(string $name): string
+ {
+ return str_replace('/', ':', $name);
+ }
+
+ private function makeAlias(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(),
+ ]));
+ }
+
+ 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);
+ return parent::run($input, $output);
+ } finally {
+ $this->kernel->shutdown();
+ }
+ }
+
+ 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/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..633665143c6 100644
--- a/src/Dev/DevelopmentAdmin.php
+++ b/src/Dev/DevelopmentAdmin.php
@@ -2,7 +2,8 @@
namespace SilverStripe\Dev;
-use Exception;
+use HttpRequestInput;
+use LogicException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
@@ -10,8 +11,8 @@
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\HybridOutput;
use SilverStripe\Security\Permission;
use SilverStripe\Security\PermissionProvider;
use SilverStripe\Security\Security;
@@ -20,47 +21,58 @@
/**
* 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' => 'App\Dev\MyHttpOnlyController',
+ * ]
*/
- 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 +94,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 +108,195 @@ protected function init()
}
}
+ /**
+ * Renders the main /dev menu in the browser
+ */
public function index()
{
- $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";
- }
-
- echo $renderer->renderFooter();
+ $renderer = DebugView::create();
+ echo $renderer->renderHeader();
+ echo $renderer->renderInfo("SilverStripe Development Tools", Director::absoluteBaseURL());
+ $base = Director::baseURL();
- // 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";
- }
- echo "\n\n";
+ echo '';
+ $evenOdd = "odd";
+ $links = $this->getLinks();
+ foreach ($links as $path => $class) {
+ // TODO: Allow for controllers
+ $description = $class::singleton()->getDescription();
+ echo "- /$path:"
+ . " $description
\n";
+ $evenOdd = ($evenOdd == "odd") ? "even" : "odd";
}
+
+ echo $renderer->renderFooter();
}
- public function runRegisteredController(HTTPRequest $request)
+ /**
+ * 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)
{
- $controllerClass = null;
+ $baseUrlPart = 'dev/' . $request->param('Action');
+ $fullPath = $request->getURL();
- $baseUrlPart = $request->param('Action');
- $reg = Config::inst()->get(static::class, 'registered_controllers');
- if (isset($reg[$baseUrlPart])) {
- $controllerClass = $reg[$baseUrlPart]['controller'];
- }
+ $links = $this->getLinks();
- if ($controllerClass && class_exists($controllerClass ?? '')) {
- return $controllerClass::create();
+ $class = $links[$fullPath] ?? null;
+ if ($class) {
+ // Tell the request we've matched the full URL
+ $request->shift($request->remaining());
+ } else {
+ $parts = explode('/', $fullPath);
+ // TODO: Loop through each set of parts - if there's a controller, use it.
+ // Don't accept commands at this stage - we're really looking for a controller that can handle the routing now,
+ // e.g. check `/dev/graphql/ide/woogie` then `dev/grahpql/ide` then `dev/graphql` don't check `dev` obviously.
+ // Check for a registered controller that can handle the request
+ $class = $links[$baseUrlPart] ?? null;
}
- $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 {
+ if (!$class) {
+ $msg = 'Error: no controller registered in ' . static::class . ' for: ' . $request->param('Action');
$this->httpError(404, $msg);
}
- }
- /*
- * Internal methods
- */
+ // Hand execution to the controller
+ if (is_a($class, Controller::class, true)) {
+ return $class::create();
+ }
+
+ /** @var HybridCommand $command */
+ $command = $class::create();
+ $input = new HttpRequestInput($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
+ );
+
+ // 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 => $command) {
+ // Check command can run in current context
+ if (!$canViewAll) {
+ if (Director::is_cli()) {
+ if (!$command::canRunInCli()) {
continue;
}
- }
-
- foreach ($registeredController['links'] as $url => $desc) {
- $links[$url] = $desc;
+ } elseif (!$command::canRunInBrowser()) {
+ continue;
}
}
- }
- return $links;
- }
-
- protected function getRegisteredController($baseUrlPart)
- {
- $reg = Config::inst()->get(static::class, 'registered_controllers');
- if (isset($reg[$baseUrlPart])) {
- $controllerClass = $reg[$baseUrlPart]['controller'];
- return $controllerClass;
+ $path = str_replace(':', '/', $name);
+ $links[$path] = $command;
}
- return null;
- }
+ // TODO add back controllers
+ // $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()) {
+ // continue;
+ // }
+ // }
+
+ // foreach ($registeredController['links'] as $url => $desc) {
+ // $links[$url] = $desc;
+ // }
+ // }
+ // }
+
+ // TODO make sure there's no duplicate routes between commands and controllers.
+ 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 +340,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..70fba288857
--- /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..390f638b08e
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/DevBuildCleanup.php
@@ -0,0 +1,79 @@
+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]);
+ }
+ }
+ }
+ }
+ }
+ 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..2d6f9e5e2a7
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/DevBuildDefaults.php
@@ -0,0 +1,68 @@
+renderHeader();
+ echo $renderer->renderInfo("Defaults Builder", Director::absoluteBaseURL());
+ echo "";
+ }
+
+ $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 "
";
+ }
+
+ if (!Director::is_cli()) {
+ echo "
";
+ echo $renderer->renderFooter();
+ }
+
+ 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..47f68bf644d
--- /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..c6e3a471d15
--- /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..6fbf641b756
--- /dev/null
+++ b/src/Dev/HybridExecution/Command/HybridCommand.php
@@ -0,0 +1,145 @@
+
+ */
+ 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.');
+ }
+ }
+ $mandatoryProperties = [
+ 'commandName',
+ 'description',
+ ];
+ foreach ($mandatoryProperties as $property) {
+ if (!static::$$property) {
+ 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..eb0990949e3
--- /dev/null
+++ b/src/Dev/HybridExecution/HttpRequestInput.php
@@ -0,0 +1,94 @@
+ $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()) {
+ $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("{$info['type']}>", 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/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/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/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 "\nCreating 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 "\nCreating 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/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
*/