From 6a3659d69d94742a11b7eaaa57558f85a2b0b343 Mon Sep 17 00:00:00 2001
From: Guy Sartorelli <36352093+GuySartorelli@users.noreply.github.com>
Date: Fri, 13 Sep 2024 17:18:15 +1200
Subject: [PATCH] Various deprecations and a few features (#11365)

* API Deprecate DatabaselessKernel

* ENH Add functionality to ArrayLib

* ENH Add functionality to DBDateTime

* API Deprecate various APIs
---
 cli-script.php                                |   8 +-
 src/Control/CliController.php                 |   8 +
 .../ConfirmationMiddleware/CliBypass.php      |  14 ++
 src/Core/BaseKernel.php                       |   8 +
 src/Core/CoreKernel.php                       |  24 +++
 src/Core/DatabaselessKernel.php               |  12 ++
 src/Dev/BuildTask.php                         |   6 +
 src/Dev/DevBuildController.php                |  17 +-
 src/Dev/DevConfigController.php               |  17 +-
 src/Dev/DevelopmentAdmin.php                  |  25 ++-
 src/Dev/Tasks/CleanupTestDatabasesTask.php    |   7 +
 src/Logging/HTTPOutputHandler.php             |  20 +-
 src/ORM/ArrayLib.php                          |  62 +++++++
 src/ORM/DatabaseAdmin.php                     |  26 +++
 src/ORM/FieldType/DBDatetime.php              |  64 +++++++
 tests/php/Logging/HTTPOutputHandlerTest.php   |  21 ++-
 tests/php/ORM/ArrayLibTest.php                | 172 ++++++++++++++++++
 tests/php/ORM/DBDatetimeTest.php              |  46 +++++
 18 files changed, 541 insertions(+), 16 deletions(-)

diff --git a/cli-script.php b/cli-script.php
index 879b2de65..c21778710 100755
--- a/cli-script.php
+++ b/cli-script.php
@@ -6,7 +6,6 @@ use SilverStripe\Control\HTTPApplication;
 use SilverStripe\Core\CoreKernel;
 use SilverStripe\ORM\DB;
 use SilverStripe\ORM\Connect\NullDatabase;
-use SilverStripe\Core\DatabaselessKernel;
 
 require __DIR__ . '/src/includes/autoload.php';
 
@@ -25,9 +24,10 @@ if ($skipDatabase) {
     DB::set_conn(new NullDatabase());
 }
 // Default application
-$kernel = $skipDatabase
-    ? new DatabaselessKernel(BASE_PATH)
-    : new CoreKernel(BASE_PATH);
+$kernel = new CoreKernel(BASE_PATH);
+if ($skipDatabase) {
+    $kernel->setBootDatabase(false);
+}
 
 $app = new HTTPApplication($kernel);
 $response = $app->handle($request);
diff --git a/src/Control/CliController.php b/src/Control/CliController.php
index 2377ebab7..30d66250b 100644
--- a/src/Control/CliController.php
+++ b/src/Control/CliController.php
@@ -4,6 +4,7 @@ namespace SilverStripe\Control;
 
 use SilverStripe\Core\ClassInfo;
 use SilverStripe\Core\Injector\Injector;
+use SilverStripe\Dev\Deprecation;
 use SilverStripe\Security\Permission;
 use SilverStripe\Security\Security;
 
@@ -13,9 +14,16 @@ use SilverStripe\Security\Security;
  * call to {@link process()} on every sub-subclass. For instance, calling
  * "sake DailyTask" from the commandline will call {@link process()} on every subclass
  * of DailyTask.
+ *
+ * @deprecated 5.4.0 Will be replaced with symfony/console commands
  */
 abstract class CliController extends Controller
 {
+    public function __construct()
+    {
+        parent::__construct();
+        Deprecation::notice('5.4.0', 'Will be replaced with symfony/console commands', Deprecation::SCOPE_CLASS);
+    }
 
     private static $allowed_actions = [
         'index'
diff --git a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php b/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
index f834cfb4e..1a16be0c6 100644
--- a/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
+++ b/src/Control/Middleware/ConfirmationMiddleware/CliBypass.php
@@ -5,12 +5,26 @@ namespace SilverStripe\Control\Middleware\ConfirmationMiddleware;
 use SilverStripe\Control\Director;
 use SilverStripe\Control\HTTPRequest;
 use SilverStripe\Core\Kernel;
+use SilverStripe\Dev\Deprecation;
 
 /**
  * Allows a bypass when the request has been run in CLI mode
+ *
+ * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
  */
 class CliBypass implements Bypass
 {
+    public function __construct()
+    {
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be removed without equivalent functionality to replace it',
+                Deprecation::SCOPE_CLASS
+            );
+        });
+    }
+
     /**
      * Returns true if the current process is running in CLI mode
      *
diff --git a/src/Core/BaseKernel.php b/src/Core/BaseKernel.php
index ee6889602..a1dbc503b 100644
--- a/src/Core/BaseKernel.php
+++ b/src/Core/BaseKernel.php
@@ -357,6 +357,14 @@ abstract class BaseKernel implements Kernel
         $this->booted = $bool;
     }
 
+    /**
+     * Check whether the kernel has booted or not
+     */
+    public function getBooted(): bool
+    {
+        return $this->booted;
+    }
+
     public function shutdown()
     {
     }
diff --git a/src/Core/CoreKernel.php b/src/Core/CoreKernel.php
index 7f968c8a3..acf43ddfc 100644
--- a/src/Core/CoreKernel.php
+++ b/src/Core/CoreKernel.php
@@ -8,18 +8,29 @@ use SilverStripe\ORM\DB;
 use Exception;
 use LogicException;
 use SilverStripe\Dev\Deprecation;
+use SilverStripe\ORM\Connect\NullDatabase;
 
 /**
  * Simple Kernel container
  */
 class CoreKernel extends BaseKernel
 {
+    protected bool $bootDatabase = true;
 
     /**
      * Indicates whether the Kernel has been flushed on boot
      */
     private ?bool $flush = null;
 
+    /**
+     * Set whether the database should boot or not.
+     */
+    public function setBootDatabase(bool $bool): static
+    {
+        $this->bootDatabase = $bool;
+        return $this;
+    }
+
     /**
      * @param false $flush
      * @throws HTTPResponse_Exception
@@ -29,6 +40,10 @@ class CoreKernel extends BaseKernel
     {
         $this->flush = $flush;
 
+        if (!$this->bootDatabase) {
+            DB::set_conn(new NullDatabase());
+        }
+
         $this->bootPHP();
         $this->bootManifests($flush);
         $this->bootErrorHandling();
@@ -47,6 +62,9 @@ class CoreKernel extends BaseKernel
      */
     protected function validateDatabase()
     {
+        if (!$this->bootDatabase) {
+            return;
+        }
         $databaseConfig = DB::getConfig();
         // Gracefully fail if no DB is configured
         if (empty($databaseConfig['database'])) {
@@ -62,6 +80,9 @@ class CoreKernel extends BaseKernel
      */
     protected function bootDatabaseGlobals()
     {
+        if (!$this->bootDatabase) {
+            return;
+        }
         // Now that configs have been loaded, we can check global for database config
         global $databaseConfig;
         global $database;
@@ -94,6 +115,9 @@ class CoreKernel extends BaseKernel
      */
     protected function bootDatabaseEnvVars()
     {
+        if (!$this->bootDatabase) {
+            return;
+        }
         // Set default database config
         $databaseConfig = $this->getDatabaseConfig();
         $databaseConfig['database'] = $this->getDatabaseName();
diff --git a/src/Core/DatabaselessKernel.php b/src/Core/DatabaselessKernel.php
index c21ec3681..c3c809dc3 100644
--- a/src/Core/DatabaselessKernel.php
+++ b/src/Core/DatabaselessKernel.php
@@ -3,6 +3,7 @@
 namespace SilverStripe\Core;
 
 use Exception;
+use SilverStripe\Dev\Deprecation;
 
 /**
  * Boot a kernel without requiring a database connection.
@@ -11,6 +12,7 @@ use Exception;
  * around the availability of a database for every execution path.
  *
  * @internal
+ * @deprecated 5.4.0 Use SilverStripe\Core\CoreKernel::setBootDatabase() instead
  */
 class DatabaselessKernel extends BaseKernel
 {
@@ -29,6 +31,16 @@ class DatabaselessKernel extends BaseKernel
      */
     protected $bootErrorHandling = true;
 
+    public function __construct($basePath)
+    {
+        parent::__construct($basePath);
+        Deprecation::notice(
+            '5.4.0',
+            'Use ' . CoreKernel::class . '::setBootDatabase() instead',
+            Deprecation::SCOPE_CLASS
+        );
+    }
+
     public function setBootErrorHandling(bool $bool)
     {
         $this->bootErrorHandling = $bool;
diff --git a/src/Dev/BuildTask.php b/src/Dev/BuildTask.php
index 9b2659c53..5497cc3f0 100644
--- a/src/Dev/BuildTask.php
+++ b/src/Dev/BuildTask.php
@@ -30,6 +30,7 @@ abstract class BuildTask
      *
      * @config
      * @var string
+     * @deprecated 5.4.0 Will be replaced with $commandName
      */
     private static $segment = null;
 
@@ -55,6 +56,7 @@ abstract class BuildTask
     /**
      * @var string $description Describe the implications the task has,
      * and the changes it makes. Accepts HTML formatting.
+     * @deprecated 5.4.0 Will be replaced with a static property with the same name
      */
     protected $description = 'No description available';
 
@@ -90,9 +92,13 @@ abstract class BuildTask
 
     /**
      * @return string HTML formatted description
+     * @deprecated 5.4.0 Will be replaced with a static method with the same name
      */
     public function getDescription()
     {
+        Deprecation::withNoReplacement(
+            fn() => Deprecation::notice('5.4.0', 'Will be replaced with a static method with the same name')
+        );
         return $this->description;
     }
 }
diff --git a/src/Dev/DevBuildController.php b/src/Dev/DevBuildController.php
index 6dc791d42..155929686 100644
--- a/src/Dev/DevBuildController.php
+++ b/src/Dev/DevBuildController.php
@@ -11,6 +11,9 @@ use SilverStripe\Security\Permission;
 use SilverStripe\Security\PermissionProvider;
 use SilverStripe\Security\Security;
 
+/**
+ * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
+ */
 class DevBuildController extends Controller implements PermissionProvider
 {
 
@@ -28,6 +31,18 @@ class DevBuildController extends Controller implements PermissionProvider
         'CAN_DEV_BUILD',
     ];
 
+    public function __construct()
+    {
+        parent::__construct();
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\DbBuild',
+                Deprecation::SCOPE_CLASS
+            );
+        });
+    }
+
     protected function init(): void
     {
         parent::init();
@@ -68,7 +83,7 @@ class DevBuildController extends Controller implements PermissionProvider
             || Permission::check(static::config()->get('init_permissions'))
         );
     }
-    
+
     public function providePermissions(): array
     {
         return [
diff --git a/src/Dev/DevConfigController.php b/src/Dev/DevConfigController.php
index 03c532810..056f0ee04 100644
--- a/src/Dev/DevConfigController.php
+++ b/src/Dev/DevConfigController.php
@@ -15,6 +15,8 @@ use Symfony\Component\Yaml\Yaml;
 
 /**
  * Outputs the full configuration.
+ *
+ * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\ConfigDump
  */
 class DevConfigController extends Controller implements PermissionProvider
 {
@@ -41,6 +43,19 @@ class DevConfigController extends Controller implements PermissionProvider
         'CAN_DEV_CONFIG',
     ];
 
+
+    public function __construct()
+    {
+        parent::__construct();
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\ConfigDump',
+                Deprecation::SCOPE_CLASS
+            );
+        });
+    }
+
     protected function init(): void
     {
         parent::init();
@@ -157,7 +172,7 @@ class DevConfigController extends Controller implements PermissionProvider
             || Permission::check(static::config()->get('init_permissions'))
         );
     }
-    
+
     public function providePermissions(): array
     {
         return [
diff --git a/src/Dev/DevelopmentAdmin.php b/src/Dev/DevelopmentAdmin.php
index 752ae82dd..ccf279c30 100644
--- a/src/Dev/DevelopmentAdmin.php
+++ b/src/Dev/DevelopmentAdmin.php
@@ -54,6 +54,7 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
      * ]
      *
      * @var array
+     * @deprecated 5.4.0 Will be replaced with "controllers" and "commands" configuration properties
      */
     private static $registered_controllers = [];
 
@@ -82,7 +83,7 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
         if (static::config()->get('deny_non_cli') && !Director::is_cli()) {
             return $this->httpError(404);
         }
-        
+
         if (!$this->canViewAll() && empty($this->getLinks())) {
             Security::permissionFailure($this);
             return;
@@ -201,8 +202,12 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
         return $links;
     }
 
+    /**
+     * @deprecated 5.4.0 Will be removed without equivalent functionality to replace it
+     */
     protected function getRegisteredController($baseUrlPart)
     {
+        Deprecation::notice('5.4.0', 'Will be removed without equivalent functionality to replace it');
         $reg = Config::inst()->get(static::class, 'registered_controllers');
 
         if (isset($reg[$baseUrlPart])) {
@@ -223,9 +228,18 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
      * DataObject classes
      * Should match the $url_handlers rule:
      *      'build/defaults' => 'buildDefaults',
+     *
+     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\DbDefaults
      */
     public function buildDefaults()
     {
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\DbDefaults'
+            );
+        });
+
         $da = DatabaseAdmin::create();
 
         $renderer = null;
@@ -247,9 +261,18 @@ class DevelopmentAdmin extends Controller implements PermissionProvider
     /**
      * Generate a secure token which can be used as a crypto key.
      * Returns the token and suggests PHP configuration to set it.
+     *
+     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Commands\GenerateSecureToken
      */
     public function generatesecuretoken()
     {
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\GenerateSecureToken'
+            );
+        });
+
         $generator = Injector::inst()->create('SilverStripe\\Security\\RandomGenerator');
         $token = $generator->randomToken('sha1');
         $body = <<<TXT
diff --git a/src/Dev/Tasks/CleanupTestDatabasesTask.php b/src/Dev/Tasks/CleanupTestDatabasesTask.php
index 77b0c397b..10c399afb 100644
--- a/src/Dev/Tasks/CleanupTestDatabasesTask.php
+++ b/src/Dev/Tasks/CleanupTestDatabasesTask.php
@@ -4,6 +4,7 @@ namespace SilverStripe\Dev\Tasks;
 
 use SilverStripe\Control\Director;
 use SilverStripe\Dev\BuildTask;
+use SilverStripe\Dev\Deprecation;
 use SilverStripe\ORM\Connect\TempDatabase;
 use SilverStripe\Security\Permission;
 use SilverStripe\Security\Security;
@@ -35,6 +36,12 @@ class CleanupTestDatabasesTask extends BuildTask
 
     public function canView(): bool
     {
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with canRunInBrowser()'
+            );
+        });
         return Permission::check('ADMIN') || Director::is_cli();
     }
 }
diff --git a/src/Logging/HTTPOutputHandler.php b/src/Logging/HTTPOutputHandler.php
index b6d922342..39df450f1 100644
--- a/src/Logging/HTTPOutputHandler.php
+++ b/src/Logging/HTTPOutputHandler.php
@@ -13,6 +13,8 @@ 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.
+ *
+ * @deprecated 5.4.0 Will be renamed to ErrorOutputHandler
  */
 class HTTPOutputHandler extends AbstractProcessingHandler
 {
@@ -32,6 +34,18 @@ class HTTPOutputHandler extends AbstractProcessingHandler
      */
     private $cliFormatter = null;
 
+    public function __construct()
+    {
+        parent::__construct();
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be renamed to ErrorOutputHandler',
+                Deprecation::SCOPE_CLASS
+            );
+        });
+    }
+
     /**
      * Get the mime type to use when displaying this error.
      *
@@ -146,7 +160,7 @@ class HTTPOutputHandler extends AbstractProcessingHandler
         // 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());
     }
 
     /**
@@ -185,10 +199,12 @@ class HTTPOutputHandler extends AbstractProcessingHandler
     }
 
     /**
-     * This method is required and must be protected for unit testing, since we can't mock static or private methods
+     * This method used to be used for unit testing but is no longer required.
+     * @deprecated 5.4.0 Use SilverStripe\Control\Director::is_cli() instead
      */
     protected function isCli(): bool
     {
+        Deprecation::notice('5.4.0', 'Use ' . Director::class . '::is_cli() instead');
         return Director::is_cli();
     }
 }
diff --git a/src/ORM/ArrayLib.php b/src/ORM/ArrayLib.php
index d3c4815e6..de2f610a6 100644
--- a/src/ORM/ArrayLib.php
+++ b/src/ORM/ArrayLib.php
@@ -4,6 +4,7 @@ namespace SilverStripe\ORM;
 
 use Generator;
 use SilverStripe\Dev\Deprecation;
+use InvalidArgumentException;
 
 /**
  * Library of static methods for manipulating arrays.
@@ -354,4 +355,65 @@ class ArrayLib
 
         $array = $shuffledArray;
     }
+
+    /**
+     * Insert a value into an array before another given value.
+     * Does not preserve keys.
+     *
+     * @param mixed $before The value to check for. If this value isn't in the source array, $insert will be put at the end.
+     * @param boolean $strict If true then this will perform a strict type comparison to look for the $before value in the source array.
+     * @param boolean $splatInsertArray If true, $insert must be an array.
+     * Its values will be splatted into the source array.
+     */
+    public static function insertBefore(array $array, mixed $insert, mixed $before, bool $strict = false, bool $splatInsertArray = false): array
+    {
+        if ($splatInsertArray && !is_array($insert)) {
+            throw new InvalidArgumentException('$insert must be an array when $splatInsertArray is true. Got ' . gettype($insert));
+        }
+        $array = array_values($array);
+        $pos = array_search($before, $array, $strict);
+        if ($pos === false) {
+            return static::insertIntoArray($array, $insert, $splatInsertArray);
+        }
+        return static::insertAtPosition($array, $insert, $pos, $splatInsertArray);
+    }
+
+    /**
+     * Insert a value into an array after another given value.
+     * Does not preserve keys.
+     *
+     * @param mixed $after The value to check for. If this value isn't in the source array, $insert will be put at the end.
+     * @param boolean $strict If true then this will perform a strict type comparison to look for the $before value in the source array.
+     * @param boolean $splatInsertArray If true, $insert must be an array.
+     * Its values will be splatted into the source array.
+     */
+    public static function insertAfter(array $array, mixed $insert, mixed $after, bool $strict = false, bool $splatInsertArray = false): array
+    {
+        if ($splatInsertArray && !is_array($insert)) {
+            throw new InvalidArgumentException('$insert must be an array when $splatInsertArray is true. Got ' . gettype($insert));
+        }
+        $array = array_values($array);
+        $pos = array_search($after, $array, $strict);
+        if ($pos === false) {
+            return static::insertIntoArray($array, $insert, $splatInsertArray);
+        }
+        return static::insertAtPosition($array, $insert, $pos + 1, $splatInsertArray);
+    }
+
+    private static function insertAtPosition(array $array, mixed $insert, int $pos, bool $splatInsertArray): array
+    {
+        $result = array_slice($array, 0, $pos);
+        $result = static::insertIntoArray($result, $insert, $splatInsertArray);
+        return array_merge($result, array_slice($array, $pos));
+    }
+
+    private static function insertIntoArray(array $array, mixed $insert, bool $splatInsertArray): array
+    {
+        if ($splatInsertArray) {
+            $array = array_merge($array, $insert);
+        } else {
+            $array[] = $insert;
+        }
+        return $array;
+    }
 }
diff --git a/src/ORM/DatabaseAdmin.php b/src/ORM/DatabaseAdmin.php
index 5b222599b..29f56474a 100644
--- a/src/ORM/DatabaseAdmin.php
+++ b/src/ORM/DatabaseAdmin.php
@@ -10,6 +10,7 @@ use SilverStripe\Core\ClassInfo;
 use SilverStripe\Core\Environment;
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Core\Manifest\ClassLoader;
+use SilverStripe\Dev\Deprecation;
 use SilverStripe\Dev\DevBuildController;
 use SilverStripe\Dev\DevelopmentAdmin;
 use SilverStripe\ORM\Connect\DatabaseException;
@@ -25,6 +26,8 @@ use SilverStripe\Versioned\Versioned;
  *
  * Utility functions for administrating the database. These can be accessed
  * via URL, e.g. http://www.yourdomain.com/db/build.
+ *
+ * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild
  */
 class DatabaseAdmin extends Controller
 {
@@ -39,6 +42,7 @@ class DatabaseAdmin extends Controller
 
     /**
      * Obsolete classname values that should be remapped in dev/build
+     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.classname_value_remapping
      */
     private static $classname_value_remapping = [
         'File'               => 'SilverStripe\\Assets\\File',
@@ -56,9 +60,22 @@ class DatabaseAdmin extends Controller
 
     /**
      * Config setting to enabled/disable the display of record counts on the dev/build output
+     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild.show_record_counts
      */
     private static $show_record_counts = true;
 
+    public function __construct()
+    {
+        parent::__construct();
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\DbBuild',
+                Deprecation::SCOPE_CLASS
+            );
+        });
+    }
+
     protected function init()
     {
         parent::init();
@@ -191,9 +208,18 @@ class DatabaseAdmin extends Controller
      *
      * @return string Returns the timestamp of the time that the database was
      *                last built
+     *
+     * @deprecated 5.4.0 Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()
      */
     public static function lastBuilt()
     {
+        Deprecation::withNoReplacement(function () {
+            Deprecation::notice(
+                '5.4.0',
+                'Will be replaced with SilverStripe\Dev\Command\DbBuild::lastBuilt()'
+            );
+        });
+
         $file = TEMP_PATH
             . DIRECTORY_SEPARATOR
             . 'database-last-generated-'
diff --git a/src/ORM/FieldType/DBDatetime.php b/src/ORM/FieldType/DBDatetime.php
index 877c707f2..cbc3b8e86 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 @@ class DBDatetime extends DBDate implements TemplateGlobalProvider
         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/tests/php/Logging/HTTPOutputHandlerTest.php b/tests/php/Logging/HTTPOutputHandlerTest.php
index 2f21f118f..065d0794c 100644
--- a/tests/php/Logging/HTTPOutputHandlerTest.php
+++ b/tests/php/Logging/HTTPOutputHandlerTest.php
@@ -6,6 +6,7 @@ use Monolog\Handler\HandlerInterface;
 use ReflectionClass;
 use ReflectionMethod;
 use SilverStripe\Control\Director;
+use SilverStripe\Core\Environment;
 use SilverStripe\Core\Injector\Injector;
 use SilverStripe\Dev\Deprecation;
 use SilverStripe\Dev\SapphireTest;
@@ -172,14 +173,20 @@ class HTTPOutputHandlerTest extends SapphireTest
         }
         $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringError);
 
-        $mockHandler = $this->getMockBuilder(HTTPOutputHandler::class)->onlyMethods(['isCli'])->getMock();
-        $mockHandler->method('isCli')->willReturn($isCli);
+        $reflectionDirector = new ReflectionClass(Environment::class);
+        $origIsCli = $reflectionDirector->getStaticPropertyValue('isCliOverride');
+        $reflectionDirector->setStaticPropertyValue('isCliOverride', $isCli);
 
-        $result = $reflectionShouldShow->invoke($mockHandler, $errorCode);
-        $this->assertSame($expected, $result);
+        try {
+            $handler = new HTTPOutputHandler();
+            $result = $reflectionShouldShow->invoke($handler, $errorCode);
+            $this->assertSame($expected, $result);
 
-        Deprecation::setShouldShowForCli($cliShouldShowOrig);
-        Deprecation::setShouldShowForHttp($httpShouldShowOrig);
-        $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
+            Deprecation::setShouldShowForCli($cliShouldShowOrig);
+            Deprecation::setShouldShowForHttp($httpShouldShowOrig);
+            $reflectionDeprecation->setStaticPropertyValue('isTriggeringError', $triggeringErrorOrig);
+        } finally {
+            $reflectionDirector->setStaticPropertyValue('isCliOverride', $origIsCli);
+        }
     }
 }
diff --git a/tests/php/ORM/ArrayLibTest.php b/tests/php/ORM/ArrayLibTest.php
index d22e871e7..39615f316 100644
--- a/tests/php/ORM/ArrayLibTest.php
+++ b/tests/php/ORM/ArrayLibTest.php
@@ -368,4 +368,176 @@ class ArrayLibTest extends SapphireTest
             }
         }
     }
+
+    public function provideInsertBefore(): array
+    {
+        return [
+            'simple insertion' => [
+                'insert' => 'new',
+                'before' => 'def',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'new', 'def', '0', null, true, 0, 'last']
+            ],
+            'insert before first' => [
+                'insert' => 'new',
+                'before' => 'abc',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['new', 'abc', '', [1,2,3], 'def', '0', null, true, 0, 'last']
+            ],
+            'insert before last' => [
+                'insert' => 'new',
+                'before' => 'last',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'new', 'last']
+            ],
+            'insert before missing' => [
+                'insert' => 'new',
+                'before' => 'this value isnt there',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last', 'new']
+            ],
+            'strict' => [
+                'insert' => 'new',
+                'before' => 0,
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 'new', 0, 'last']
+            ],
+            'not strict' => [
+                'insert' => 'new',
+                'before' => 0,
+                'strict' => false,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', 'new', '0', null, true, 0, 'last']
+            ],
+            'before array' => [
+                'insert' => 'new',
+                'before' => [1,2,3],
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', 'new', [1,2,3], 'def', '0', null, true, 0, 'last']
+            ],
+            'before missing array' => [
+                'insert' => 'new',
+                'before' => ['a', 'b', 'c'],
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last', 'new']
+            ],
+            'splat array' => [
+                'insert' => ['a', 'b', 'c'],
+                'before' => 'def',
+                'strict' => true,
+                'splat' => true,
+                'expected' => ['abc', '', [1,2,3], 'a', 'b', 'c', 'def', '0', null, true, 0, 'last']
+            ],
+            'no splat array' => [
+                'insert' => ['a', 'b', 'c'],
+                'before' => 'def',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], ['a', 'b', 'c'], 'def', '0', null, true, 0, 'last']
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideInsertBefore
+     */
+    public function testInsertBefore(mixed $insert, mixed $before, bool $strict, bool $splat, array $expected): void
+    {
+        $array = ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last'];
+        $final = ArrayLib::insertBefore($array, $insert, $before, $strict, $splat);
+        $this->assertSame($expected, $final);
+    }
+
+    public function provideInsertAfter(): array
+    {
+        return [
+            'simple insertion' => [
+                'insert' => 'new',
+                'before' => 'def',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', 'new', '0', null, true, 0, 'last']
+            ],
+            'insert after first' => [
+                'insert' => 'new',
+                'before' => 'abc',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', 'new', '', [1,2,3], 'def', '0', null, true, 0, 'last']
+            ],
+            'insert after last' => [
+                'insert' => 'new',
+                'before' => 'last',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last', 'new']
+            ],
+            'insert after missing' => [
+                'insert' => 'new',
+                'before' => 'this value isnt there',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last', 'new']
+            ],
+            'strict' => [
+                'insert' => 'new',
+                'before' => 0,
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'new', 'last']
+            ],
+            'not strict' => [
+                'insert' => 'new',
+                'before' => 0,
+                'strict' => false,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', 'new', null, true, 0, 'last']
+            ],
+            'after array' => [
+                'insert' => 'new',
+                'before' => [1,2,3],
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'new', 'def', '0', null, true, 0, 'last']
+            ],
+            'after missing array' => [
+                'insert' => 'new',
+                'before' => ['a', 'b', 'c'],
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last', 'new']
+            ],
+            'splat array' => [
+                'insert' => ['a', 'b', 'c'],
+                'before' => 'def',
+                'strict' => true,
+                'splat' => true,
+                'expected' => ['abc', '', [1,2,3], 'def', 'a', 'b', 'c', '0', null, true, 0, 'last']
+            ],
+            'no splat array' => [
+                'insert' => ['a', 'b', 'c'],
+                'before' => 'def',
+                'strict' => true,
+                'splat' => false,
+                'expected' => ['abc', '', [1,2,3], 'def', ['a', 'b', 'c'], '0', null, true, 0, 'last']
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideInsertAfter
+     */
+    public function testInsertAfter(mixed $insert, mixed $after, bool $strict, bool $splat, array $expected): void
+    {
+        $array = ['abc', '', [1,2,3], 'def', '0', null, true, 0, 'last'];
+        $final = ArrayLib::insertAfter($array, $insert, $after, $strict, $splat);
+        $this->assertSame($expected, $final);
+    }
 }
diff --git a/tests/php/ORM/DBDatetimeTest.php b/tests/php/ORM/DBDatetimeTest.php
index 71b24be22..9196016d8 100644
--- a/tests/php/ORM/DBDatetimeTest.php
+++ b/tests/php/ORM/DBDatetimeTest.php
@@ -302,4 +302,50 @@ class DBDatetimeTest extends SapphireTest
             ['-59 seconds', '2019-03-03 11:59:01'],
         ];
     }
+
+    public function provideGetTimeBetween(): array
+    {
+        return [
+            'no time between' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2019-03-03 12:00:00',
+                'expected' => '0 seconds',
+            ],
+            'one second between' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2019-03-03 12:00:01',
+                'expected' => 'one second',
+            ],
+            'some seconds between' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2019-03-03 12:00:15',
+                'expected' => '15 seconds',
+            ],
+            'days and minutes between' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2019-03-15 12:05:00',
+                'expected' => '12 days, 5 minutes',
+            ],
+            'years, months, and hours between' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2028-01-03 17:00:00',
+                'expected' => '8 years, 10 months, 5 hours',
+            ],
+            'backwards in time doesnt say "negative" or "-"' => [
+                'timeBefore' => '2019-03-03 12:00:00',
+                'timeAfter' => '2018-01-06 12:01:12',
+                'expected' => 'one year, one month, 27 days, 23 hours, 58 minutes, 48 seconds',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideGetTimeBetween
+     */
+    public function testGetTimeBetween(string $timeBefore, string $timeAfter, string $expected): void
+    {
+        $before = (new DBDateTime())->setValue($timeBefore);
+        $after = (new DBDateTime())->setValue($timeAfter);
+        $this->assertSame($expected, DBDatetime::getTimeBetween($before, $after));
+    }
 }