Merge branch 'silverstripe:5' into pull/5/custom-script-with-attributes
This commit is contained in:
commit
667b0c1221
|
@ -67,6 +67,7 @@ SilverStripe\Control\HTTP:
|
|||
box: application/vnd.previewsystems.box
|
||||
boz: application/x-bzip2
|
||||
bpk: application/octet-stream
|
||||
brf: text/plain
|
||||
btif: image/prs.btif
|
||||
bz: application/x-bzip
|
||||
bz2: application/x-bzip2
|
||||
|
|
|
@ -87,7 +87,7 @@ SilverStripe\Core\Injector\Injector:
|
|||
DevUrlsConfirmationMiddleware: '%$DevUrlsConfirmationMiddleware'
|
||||
|
||||
DevUrlsConfirmationMiddleware:
|
||||
class: SilverStripe\Control\Middleware\PermissionAwareConfirmationMiddleware
|
||||
class: SilverStripe\Control\Middleware\DevelopmentAdminConfirmationMiddleware
|
||||
constructor:
|
||||
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev")'
|
||||
properties:
|
||||
|
@ -97,8 +97,6 @@ SilverStripe\Core\Injector\Injector:
|
|||
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\CliBypass'
|
||||
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\EnvironmentBypass("dev")'
|
||||
EnforceAuthentication: false
|
||||
AffectedPermissions:
|
||||
- ADMIN
|
||||
|
||||
---
|
||||
Name: dev_urls-confirmation-exceptions
|
||||
|
@ -123,9 +121,6 @@ SilverStripe\Core\Injector\Injector:
|
|||
DevUrlsConfirmationMiddleware:
|
||||
properties:
|
||||
Bypasses:
|
||||
# dev/build is covered by URLSpecialsMiddleware
|
||||
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/build")'
|
||||
|
||||
# The confirmation form is where people will be redirected for confirmation. We don't want to block it.
|
||||
- '%$SilverStripe\Control\Middleware\ConfirmationMiddleware\UrlPathStartswith("dev/confirm")'
|
||||
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\Control\Middleware;
|
||||
|
||||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\Dev\DevelopmentAdmin;
|
||||
use SilverStripe\Security\Permission;
|
||||
|
||||
/**
|
||||
* Extends the PermissionAwareConfirmationMiddleware with checks for user permissions
|
||||
*
|
||||
* Respects users who don't have enough access and does not
|
||||
* ask them for confirmation
|
||||
*
|
||||
* By default it enforces authentication by redirecting users to a login page.
|
||||
*
|
||||
* How it works:
|
||||
* - if user can bypass the middleware, then pass request further
|
||||
* - if there are no confirmation items, then pass request further
|
||||
* - if user is not authenticated and enforceAuthentication is false, then pass request further
|
||||
* - if user does not have at least one of the affected permissions, then pass request further
|
||||
* - otherwise, pass handling to the parent (ConfirmationMiddleware)
|
||||
*/
|
||||
class DevelopmentAdminConfirmationMiddleware extends PermissionAwareConfirmationMiddleware
|
||||
{
|
||||
|
||||
/**
|
||||
* Check whether the user has permissions to perform the target operation
|
||||
* Otherwise we may want to skip the confirmation dialog.
|
||||
*
|
||||
* WARNING! The user has to be authenticated beforehand
|
||||
*
|
||||
* @param HTTPRequest $request
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasAccess(HTTPRequest $request)
|
||||
{
|
||||
$action = $request->remaining();
|
||||
if (empty($action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$registeredRoutes = DevelopmentAdmin::config()->get('registered_controllers');
|
||||
while (!isset($registeredRoutes[$action]) && strpos($action, '/') !== false) {
|
||||
// Check for the parent route if a specific route isn't found
|
||||
$action = substr($action, 0, strrpos($action, '/'));
|
||||
}
|
||||
|
||||
if (isset($registeredRoutes[$action]['controller'])) {
|
||||
$initPermissions = Config::forClass($registeredRoutes[$action]['controller'])->get('init_permissions');
|
||||
foreach ($initPermissions as $permission) {
|
||||
if (Permission::check($permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -39,8 +39,7 @@ class URLSpecialsMiddleware extends PermissionAwareConfirmationMiddleware
|
|||
parent::__construct(
|
||||
new ConfirmationMiddleware\GetParameter("flush"),
|
||||
new ConfirmationMiddleware\GetParameter("isDev"),
|
||||
new ConfirmationMiddleware\GetParameter("isTest"),
|
||||
new ConfirmationMiddleware\UrlPathStartswith("dev/build")
|
||||
new ConfirmationMiddleware\GetParameter("isTest")
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -159,14 +159,23 @@ class Deprecation
|
|||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
if (Environment::hasEnv('SS_DEPRECATION_ENABLED')) {
|
||||
$envVar = Environment::getEnv('SS_DEPRECATION_ENABLED');
|
||||
return self::varAsBoolean($envVar);
|
||||
$hasEnv = Environment::hasEnv('SS_DEPRECATION_ENABLED');
|
||||
|
||||
// Return early if disabled
|
||||
if ($hasEnv && !Environment::getEnv('SS_DEPRECATION_ENABLED')) {
|
||||
return false;
|
||||
}
|
||||
if (!$hasEnv && !static::$currentlyEnabled) {
|
||||
// Static property is ignored if SS_DEPRECATION_ENABLED was set
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's enabled, explicitly don't allow for non-dev environments
|
||||
if (!Director::isDev()) {
|
||||
return false;
|
||||
}
|
||||
return static::$currentlyEnabled;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,8 +7,11 @@ use SilverStripe\Control\Director;
|
|||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Control\HTTPResponse;
|
||||
use SilverStripe\ORM\DatabaseAdmin;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionProvider;
|
||||
use SilverStripe\Security\Security;
|
||||
|
||||
class DevBuildController extends Controller
|
||||
class DevBuildController extends Controller implements PermissionProvider
|
||||
{
|
||||
|
||||
private static $url_handlers = [
|
||||
|
@ -19,6 +22,21 @@ class DevBuildController extends Controller
|
|||
'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()) {
|
||||
|
@ -39,4 +57,27 @@ class DevBuildController extends Controller
|
|||
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
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,13 +7,16 @@ use SilverStripe\Control\Director;
|
|||
use SilverStripe\Control\HTTPResponse;
|
||||
use SilverStripe\Core\ClassInfo;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionProvider;
|
||||
use SilverStripe\Security\Security;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Outputs the full configuration.
|
||||
*/
|
||||
class DevConfigController extends Controller
|
||||
class DevConfigController extends Controller implements PermissionProvider
|
||||
{
|
||||
|
||||
/**
|
||||
|
@ -32,6 +35,21 @@ class DevConfigController extends Controller
|
|||
'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()
|
||||
*
|
||||
|
@ -129,6 +147,29 @@ class DevConfigController extends Controller
|
|||
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
|
||||
*
|
||||
|
|
|
@ -2,17 +2,20 @@
|
|||
|
||||
namespace SilverStripe\Dev;
|
||||
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use Exception;
|
||||
use SilverStripe\Control\Controller;
|
||||
use SilverStripe\Control\Director;
|
||||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Control\HTTPResponse;
|
||||
use SilverStripe\Control\Controller;
|
||||
use SilverStripe\Versioned\Versioned;
|
||||
use SilverStripe\Core\ClassInfo;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\ORM\DatabaseAdmin;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionProvider;
|
||||
use SilverStripe\Security\Security;
|
||||
use Exception;
|
||||
use SilverStripe\Versioned\Versioned;
|
||||
|
||||
/**
|
||||
* Base class for development tools.
|
||||
|
@ -20,7 +23,7 @@ use Exception;
|
|||
* Configured in framework/_config/dev.yml, with the config key registeredControllers being
|
||||
* used to generate the list of links for /dev.
|
||||
*/
|
||||
class DevelopmentAdmin extends Controller
|
||||
class DevelopmentAdmin extends Controller implements PermissionProvider
|
||||
{
|
||||
|
||||
private static $url_handlers = [
|
||||
|
@ -79,22 +82,8 @@ class DevelopmentAdmin extends Controller
|
|||
if (static::config()->get('deny_non_cli') && !Director::is_cli()) {
|
||||
return $this->httpError(404);
|
||||
}
|
||||
|
||||
// Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957)
|
||||
$requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0)
|
||||
&& (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false);
|
||||
|
||||
// We allow access to this controller regardless of live-status or ADMIN permission only
|
||||
// if on CLI. Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
|
||||
$allowAllCLI = static::config()->get('allow_all_cli');
|
||||
$canAccess = (
|
||||
$requestedDevBuild
|
||||
|| Director::isDev()
|
||||
|| (Director::is_cli() && $allowAllCLI)
|
||||
// Its important that we don't run this check if dev/build was requested
|
||||
|| Permission::check("ADMIN")
|
||||
);
|
||||
if (!$canAccess) {
|
||||
|
||||
if (!$this->canViewAll() && empty($this->getLinks())) {
|
||||
Security::permissionFailure($this);
|
||||
return;
|
||||
}
|
||||
|
@ -109,6 +98,7 @@ class DevelopmentAdmin extends Controller
|
|||
|
||||
public function index()
|
||||
{
|
||||
$links = $this->getLinks();
|
||||
// Web mode
|
||||
if (!Director::is_cli()) {
|
||||
$renderer = DebugView::create();
|
||||
|
@ -118,7 +108,7 @@ class DevelopmentAdmin extends Controller
|
|||
|
||||
echo '<div class="options"><ul>';
|
||||
$evenOdd = "odd";
|
||||
foreach (self::get_links() as $action => $description) {
|
||||
foreach ($links as $action => $description) {
|
||||
echo "<li class=\"$evenOdd\"><a href=\"{$base}dev/$action\"><b>/dev/$action:</b>"
|
||||
. " $description</a></li>\n";
|
||||
$evenOdd = ($evenOdd == "odd") ? "even" : "odd";
|
||||
|
@ -130,7 +120,7 @@ class DevelopmentAdmin extends Controller
|
|||
} else {
|
||||
echo "SILVERSTRIPE DEVELOPMENT TOOLS\n--------------------------\n\n";
|
||||
echo "You can execute any of the following commands:\n\n";
|
||||
foreach (self::get_links() as $action => $description) {
|
||||
foreach ($links as $action => $description) {
|
||||
echo " sake dev/$action: $description\n";
|
||||
}
|
||||
echo "\n\n";
|
||||
|
@ -160,18 +150,17 @@ class DevelopmentAdmin extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Internal methods
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated 5.2.0 use getLinks() instead to include permission checks
|
||||
* @return array of url => description
|
||||
*/
|
||||
protected static function get_links()
|
||||
{
|
||||
Deprecation::notice('5.2.0', 'Use getLinks() instead to include permission checks');
|
||||
$links = [];
|
||||
|
||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
||||
|
@ -185,6 +174,33 @@ class DevelopmentAdmin extends Controller
|
|||
return $links;
|
||||
}
|
||||
|
||||
protected function getLinks(): array
|
||||
{
|
||||
$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()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($registeredController['links'] as $url => $desc) {
|
||||
$links[$url] = $desc;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $links;
|
||||
}
|
||||
|
||||
protected function getRegisteredController($baseUrlPart)
|
||||
{
|
||||
$reg = Config::inst()->get(static::class, 'registered_controllers');
|
||||
|
@ -198,8 +214,6 @@ class DevelopmentAdmin extends Controller
|
|||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Unregistered (hidden) actions
|
||||
*/
|
||||
|
@ -253,4 +267,39 @@ TXT;
|
|||
{
|
||||
$this->redirect("Debug_");
|
||||
}
|
||||
|
||||
public function providePermissions(): array
|
||||
{
|
||||
return [
|
||||
'ALL_DEV_ADMIN' => [
|
||||
'name' => _t(__CLASS__ . '.ALL_DEV_ADMIN_DESCRIPTION', 'Can view and execute all /dev endpoints'),
|
||||
'help' => _t(__CLASS__ . '.ALL_DEV_ADMIN_HELP', 'Can view and execute all /dev endpoints'),
|
||||
'category' => static::permissionsCategory(),
|
||||
'sort' => 50
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function permissionsCategory(): string
|
||||
{
|
||||
return _t(__CLASS__ . 'PERMISSIONS_CATEGORY', 'Dev permissions');
|
||||
}
|
||||
|
||||
protected function canViewAll(): bool
|
||||
{
|
||||
// Special case for dev/build: Defer permission checks to DatabaseAdmin->init() (see #4957)
|
||||
$requestedDevBuild = (stripos($this->getRequest()->getURL() ?? '', 'dev/build') === 0)
|
||||
&& (stripos($this->getRequest()->getURL() ?? '', 'dev/build/defaults') === false);
|
||||
|
||||
// We allow access to this controller regardless of live-status or ADMIN permission only
|
||||
// if on CLI. Access to this controller is always allowed in "dev-mode", or of the user is ADMIN.
|
||||
$allowAllCLI = static::config()->get('allow_all_cli');
|
||||
return (
|
||||
$requestedDevBuild
|
||||
|| Director::isDev()
|
||||
|| (Director::is_cli() && $allowAllCLI)
|
||||
// Its important that we don't run this check if dev/build was requested
|
||||
|| Permission::check(['ADMIN', 'ALL_DEV_ADMIN'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,11 +13,12 @@ use SilverStripe\Core\Injector\Injector;
|
|||
use SilverStripe\Core\Manifest\ModuleResourceLoader;
|
||||
use SilverStripe\ORM\ArrayList;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionProvider;
|
||||
use SilverStripe\Security\Security;
|
||||
use SilverStripe\View\ArrayData;
|
||||
use SilverStripe\View\ViewableData;
|
||||
|
||||
class TaskRunner extends Controller
|
||||
class TaskRunner extends Controller implements PermissionProvider
|
||||
{
|
||||
|
||||
use Configurable;
|
||||
|
@ -32,6 +33,12 @@ class TaskRunner extends Controller
|
|||
'runTask',
|
||||
];
|
||||
|
||||
private static $init_permissions = [
|
||||
'ADMIN',
|
||||
'ALL_DEV_ADMIN',
|
||||
'BUILDTASK_CAN_RUN',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
|
@ -43,15 +50,7 @@ class TaskRunner extends Controller
|
|||
{
|
||||
parent::init();
|
||||
|
||||
$allowAllCLI = DevelopmentAdmin::config()->get('allow_all_cli');
|
||||
$canAccess = (
|
||||
Director::isDev()
|
||||
// We need to ensure that DevelopmentAdminTest can simulate permission failures when running
|
||||
// "dev/tasks" from CLI.
|
||||
|| (Director::is_cli() && $allowAllCLI)
|
||||
|| Permission::check("ADMIN")
|
||||
);
|
||||
if (!$canAccess) {
|
||||
if (!$this->canInit()) {
|
||||
Security::permissionFailure($this);
|
||||
}
|
||||
}
|
||||
|
@ -119,8 +118,8 @@ class TaskRunner extends Controller
|
|||
$inst = Injector::inst()->create($task['class']);
|
||||
$title(sprintf('Running Task %s', $inst->getTitle()));
|
||||
|
||||
if (!$inst->isEnabled()) {
|
||||
$message('The task is disabled');
|
||||
if (!$this->taskEnabled($task['class'])) {
|
||||
$message('The task is disabled or you do not have sufficient permission to run it');
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -129,7 +128,7 @@ class TaskRunner extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
$message(sprintf('The build task "%s" could not be found', Convert::raw2xml($name)));
|
||||
$message(sprintf('The build task "%s" could not be found, is disabled or you do not have sufficient permission to run it', Convert::raw2xml($name)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,15 +138,7 @@ class TaskRunner extends Controller
|
|||
{
|
||||
$availableTasks = [];
|
||||
|
||||
$taskClasses = ClassInfo::subclassesFor(BuildTask::class);
|
||||
// remove the base class
|
||||
array_shift($taskClasses);
|
||||
|
||||
foreach ($taskClasses as $class) {
|
||||
if (!$this->taskEnabled($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($this->getTaskList() as $class) {
|
||||
$singleton = BuildTask::singleton($class);
|
||||
$description = $singleton->getDescription();
|
||||
$description = trim($description ?? '');
|
||||
|
@ -167,6 +158,18 @@ class TaskRunner extends Controller
|
|||
return $availableTasks;
|
||||
}
|
||||
|
||||
protected function getTaskList(): array
|
||||
{
|
||||
$taskClasses = ClassInfo::subclassesFor(BuildTask::class, false);
|
||||
foreach ($taskClasses as $index => $task) {
|
||||
if (!$this->taskEnabled($task)) {
|
||||
unset($taskClasses[$index]);
|
||||
}
|
||||
}
|
||||
|
||||
return $taskClasses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $class
|
||||
* @return boolean
|
||||
|
@ -176,11 +179,29 @@ class TaskRunner extends Controller
|
|||
$reflectionClass = new ReflectionClass($class);
|
||||
if ($reflectionClass->isAbstract()) {
|
||||
return false;
|
||||
} elseif (!singleton($class)->isEnabled()) {
|
||||
}
|
||||
|
||||
$task = Injector::inst()->get($class);
|
||||
if (!$task->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
if ($task->hasMethod('canView') && !$task->canView()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->canViewAllTasks();
|
||||
}
|
||||
|
||||
protected function canViewAllTasks(): 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'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -207,4 +228,24 @@ class TaskRunner extends Controller
|
|||
|
||||
return $header;
|
||||
}
|
||||
|
||||
public function canInit(): bool
|
||||
{
|
||||
if ($this->canViewAllTasks()) {
|
||||
return true;
|
||||
}
|
||||
return count($this->getTaskList()) > 0;
|
||||
}
|
||||
|
||||
public function providePermissions(): array
|
||||
{
|
||||
return [
|
||||
'BUILDTASK_CAN_RUN' => [
|
||||
'name' => _t(__CLASS__ . '.BUILDTASK_CAN_RUN_DESCRIPTION', 'Can view and execute all /dev/tasks'),
|
||||
'help' => _t(__CLASS__ . '.BUILDTASK_CAN_RUN_HELP', 'Can view and execute all Build Tasks (/dev/tasks). This may still be overriden by individual task view permissions'),
|
||||
'category' => DevelopmentAdmin::permissionsCategory(),
|
||||
'sort' => 70
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,15 +23,18 @@ class CleanupTestDatabasesTask extends BuildTask
|
|||
|
||||
public function run($request)
|
||||
{
|
||||
if (!Permission::check('ADMIN') && !Director::is_cli()) {
|
||||
if (!$this->canView()) {
|
||||
$response = Security::permissionFailure();
|
||||
if ($response) {
|
||||
$response->output();
|
||||
}
|
||||
die;
|
||||
}
|
||||
|
||||
// Delete all temp DBs
|
||||
TempDatabase::create()->deleteAll();
|
||||
}
|
||||
|
||||
public function canView(): bool
|
||||
{
|
||||
return Permission::check('ADMIN') || Director::is_cli();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
namespace SilverStripe\Forms;
|
||||
|
||||
use LogicException;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\DataObjectInterface;
|
||||
use SilverStripe\Security\Authenticator;
|
||||
use SilverStripe\Security\Security;
|
||||
use SilverStripe\View\HTML;
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Two masked input fields, checks for matching passwords.
|
||||
|
@ -43,12 +45,20 @@ class ConfirmedPasswordField extends FormField
|
|||
public $requireStrongPassword = false;
|
||||
|
||||
/**
|
||||
* Allow empty fields in serverside validation
|
||||
* Allow empty fields when entering the password for the first time
|
||||
* If this is set to true then a random password may be generated if the field is empty
|
||||
* depending on the value of $self::generateRandomPasswordOnEmtpy
|
||||
*
|
||||
* @var boolean
|
||||
*/
|
||||
public $canBeEmpty = false;
|
||||
|
||||
/**
|
||||
* Callback used to generate a random password if $this->canBeEmpty is true and the field is left blank
|
||||
* If this is set to null then a random password will not be generated
|
||||
*/
|
||||
private ?Closure $randomPasswordCallback = null;
|
||||
|
||||
/**
|
||||
* If set to TRUE, the "password" and "confirm password" form fields will
|
||||
* be hidden via CSS and JavaScript by default, and triggered by a link.
|
||||
|
@ -255,7 +265,27 @@ class ConfirmedPasswordField extends FormField
|
|||
public function setCanBeEmpty($value)
|
||||
{
|
||||
$this->canBeEmpty = (bool)$value;
|
||||
$this->updateRightTitle();
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the callback used to generate a random password
|
||||
*/
|
||||
public function getRandomPasswordCallback(): ?Closure
|
||||
{
|
||||
return $this->randomPasswordCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a callback used to generate a random password if canBeEmpty is set to true
|
||||
* and the password field is left blank
|
||||
* If this is set to null then a random password will not be generated
|
||||
*/
|
||||
public function setRandomPasswordCallback(?Closure $callback): static
|
||||
{
|
||||
$this->randomPasswordCallback = $callback;
|
||||
$this->updateRightTitle();
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
@ -552,9 +582,7 @@ class ConfirmedPasswordField extends FormField
|
|||
}
|
||||
|
||||
/**
|
||||
* Only save if field was shown on the client, and is not empty.
|
||||
*
|
||||
* @param DataObjectInterface $record
|
||||
* Only save if field was shown on the client, and is not empty or random password generation is enabled
|
||||
*/
|
||||
public function saveInto(DataObjectInterface $record)
|
||||
{
|
||||
|
@ -562,7 +590,18 @@ class ConfirmedPasswordField extends FormField
|
|||
return;
|
||||
}
|
||||
|
||||
if (!($this->canBeEmpty && !$this->value)) {
|
||||
// Create a random password if password is blank and the flag is set
|
||||
if (!$this->value
|
||||
&& $this->canBeEmpty
|
||||
&& $this->randomPasswordCallback
|
||||
) {
|
||||
if (!is_callable($this->randomPasswordCallback)) {
|
||||
throw new LogicException('randomPasswordCallback must be callable');
|
||||
}
|
||||
$this->value = call_user_func_array($this->randomPasswordCallback, [$this->maxLength ?: 0]);
|
||||
}
|
||||
|
||||
if ($this->value || $this->canBeEmtpy) {
|
||||
parent::saveInto($record);
|
||||
}
|
||||
}
|
||||
|
@ -694,4 +733,21 @@ class ConfirmedPasswordField extends FormField
|
|||
{
|
||||
return $this->requireStrongPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a warning to the right title, or removes that appended warning.
|
||||
*/
|
||||
private function updateRightTitle(): void
|
||||
{
|
||||
$text = _t(
|
||||
__CLASS__ . '.RANDOM_IF_EMPTY',
|
||||
'If this is left blank then a random password will be automatically generated.'
|
||||
);
|
||||
$rightTitle = $this->passwordField->RightTitle() ?? '';
|
||||
$rightTitle = trim(str_replace($text, '', $rightTitle));
|
||||
if ($this->canBeEmpty && $this->randomPasswordCallback) {
|
||||
$rightTitle = $text . ' ' . $rightTitle;
|
||||
}
|
||||
$this->passwordField->setRightTitle($rightTitle ?: null);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -477,7 +477,8 @@ class Form extends ViewableData implements HasRequestHandler
|
|||
|
||||
/**
|
||||
* Populate this form with messages from the given ValidationResult.
|
||||
* Note: This will not clear any pre-existing messages
|
||||
* Note: This will try not to clear any pre-existing messages, but will clear them
|
||||
* if a new message has a different message type or cast than the existing ones.
|
||||
*
|
||||
* @param ValidationResult $result
|
||||
* @return $this
|
||||
|
@ -494,7 +495,7 @@ class Form extends ViewableData implements HasRequestHandler
|
|||
$owner = $this;
|
||||
}
|
||||
|
||||
$owner->setMessage($message['message'], $message['messageType'], $message['messageCast']);
|
||||
$owner->appendMessage($message['message'], $message['messageType'], $message['messageCast'], true);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
|
|
@ -31,7 +31,6 @@ trait FormMessage
|
|||
*/
|
||||
protected $messageCast = null;
|
||||
|
||||
|
||||
/**
|
||||
* Returns the field message, used by form validation.
|
||||
*
|
||||
|
@ -92,6 +91,58 @@ trait FormMessage
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a message to the existing message if the types and casts match.
|
||||
* If either is different, the $force argument determines the behaviour.
|
||||
*
|
||||
* Note: to prevent duplicates, we check for the $message string in the existing message.
|
||||
* If the existing message contains $message as a substring, it won't be added.
|
||||
*
|
||||
* @param bool $force if true, and the new message cannot be appended to the existing one, the existing message will be overridden.
|
||||
* @throws InvalidArgumentException if $force is false and the messages can't be merged because of a mismatched type or cast.
|
||||
*/
|
||||
public function appendMessage(
|
||||
string $message,
|
||||
string $messageType = ValidationResult::TYPE_ERROR,
|
||||
string $messageCast = ValidationResult::CAST_TEXT,
|
||||
bool $force = false,
|
||||
): static {
|
||||
if (empty($message)) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (empty($this->message)) {
|
||||
return $this->setMessage($message, $messageType, $messageCast);
|
||||
}
|
||||
|
||||
$canBeMerged = ($messageType === $this->getMessageType() && $messageCast === $this->getMessageCast());
|
||||
|
||||
if (!$canBeMerged && !$force) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Couldn't append message of type %s and cast %s to existing message of type %s and cast %s",
|
||||
$messageType,
|
||||
$messageCast,
|
||||
$this->getMessageType(),
|
||||
$this->getMessageCast(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Checks that the exact message string is not already contained before appending
|
||||
$messageContainsString = strpos($this->message, $message) !== false;
|
||||
if ($canBeMerged && $messageContainsString) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($canBeMerged) {
|
||||
$separator = $messageCast === ValidationResult::CAST_HTML ? '<br />' : PHP_EOL;
|
||||
$message = $this->message . $separator . $message;
|
||||
}
|
||||
|
||||
return $this->setMessage($message, $messageType, $messageCast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get casting helper for message cast, or null if not known
|
||||
*
|
||||
|
|
|
@ -116,8 +116,15 @@ class RequiredFields extends Validator
|
|||
$error = (count($value ?? [])) ? false : true;
|
||||
}
|
||||
} else {
|
||||
// assume a string or integer
|
||||
$error = (strlen($value ?? '')) ? false : true;
|
||||
$stringValue = (string) $value;
|
||||
if ($formField instanceof TreeDropdownField) {
|
||||
// test for blank string as well as '0' because older versions of silverstripe/admin FormBuilder
|
||||
// forms created using redux-form would have a value of null for unsaved records
|
||||
// the null value will have been converted to '' by the time it gets to this point
|
||||
$error = in_array($stringValue, ['0', '']);
|
||||
} else {
|
||||
$error = strlen($stringValue) > 0 ? false : true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($formField && $error) {
|
||||
|
|
|
@ -9,6 +9,7 @@ use SilverStripe\Forms\CompositeField;
|
|||
use SilverStripe\Forms\Form;
|
||||
use SilverStripe\Forms\FormField;
|
||||
use SilverStripe\ORM\ValidationResult;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Represents a {@link Form} as structured data which allows a frontend library to render it.
|
||||
|
@ -112,9 +113,29 @@ class FormSchema
|
|||
$schema['fields'][] = $field->getSchemaData();
|
||||
}
|
||||
|
||||
// Validate there are react components for all fields
|
||||
// Note 'actions' (FormActions) are always valid because FormAction.schemaComponent has a default value
|
||||
$this->recursivelyValidateSchemaData($schema['fields']);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
private function recursivelyValidateSchemaData(array $schemaData)
|
||||
{
|
||||
foreach ($schemaData as $data) {
|
||||
if (!$data['schemaType'] && !$data['component']) {
|
||||
$name = $data['name'];
|
||||
$message = "Could not find a react component for field \"$name\"."
|
||||
. "Replace or remove the field instance from the field list,"
|
||||
. ' or update the field class and set the schemaDataType or schemaComponent property.';
|
||||
throw new LogicException($message);
|
||||
}
|
||||
if (array_key_exists('children', $data)) {
|
||||
$this->recursivelyValidateSchemaData($data['children']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current state of this form as a nested array.
|
||||
*
|
||||
|
|
|
@ -250,7 +250,12 @@ class TreeDropdownField extends FormField
|
|||
|
||||
$this->addExtraClass('single');
|
||||
|
||||
parent::__construct($name, $title);
|
||||
// Set a default value of 0 instead of null
|
||||
// Because TreedropdownField requires SourceObject to have the Hierarchy extension, make the default
|
||||
// value the same as the default value for a RelationID, which is 0.
|
||||
$value = 0;
|
||||
|
||||
parent::__construct($name, $title, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -984,4 +989,16 @@ class TreeDropdownField extends FormField
|
|||
$this->showSelectedPath = $showSelectedPath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getSchemaValidation()
|
||||
{
|
||||
$validationList = parent::getSchemaValidation();
|
||||
if (array_key_exists('required', $validationList)) {
|
||||
$validationList['required'] = ['extraEmptyValues' => ['0']];
|
||||
}
|
||||
return $validationList;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace SilverStripe\ORM\Connect;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use SilverStripe\Core\Convert;
|
||||
use SilverStripe\ORM\Queries\SQLExpression;
|
||||
use SilverStripe\ORM\Queries\SQLSelect;
|
||||
use SilverStripe\ORM\Queries\SQLDelete;
|
||||
|
@ -68,13 +69,24 @@ class DBQueryBuilder
|
|||
*/
|
||||
protected function buildSelectQuery(SQLSelect $query, array &$parameters)
|
||||
{
|
||||
$sql = $this->buildSelectFragment($query, $parameters);
|
||||
$needsParenthisis = count($query->getUnions()) > 0;
|
||||
$nl = $this->getSeparator();
|
||||
$sql = '';
|
||||
if ($needsParenthisis) {
|
||||
$sql .= "({$nl}";
|
||||
}
|
||||
$sql .= $this->buildWithFragment($query, $parameters);
|
||||
$sql .= $this->buildSelectFragment($query, $parameters);
|
||||
$sql .= $this->buildFromFragment($query, $parameters);
|
||||
$sql .= $this->buildWhereFragment($query, $parameters);
|
||||
$sql .= $this->buildGroupByFragment($query, $parameters);
|
||||
$sql .= $this->buildHavingFragment($query, $parameters);
|
||||
$sql .= $this->buildOrderByFragment($query, $parameters);
|
||||
$sql .= $this->buildLimitFragment($query, $parameters);
|
||||
if ($needsParenthisis) {
|
||||
$sql .= "{$nl})";
|
||||
}
|
||||
$sql .= $this->buildUnionFragment($query, $parameters);
|
||||
return $sql;
|
||||
}
|
||||
|
||||
|
@ -155,6 +167,41 @@ class DBQueryBuilder
|
|||
return $sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the WITH clauses ready for inserting into a query.
|
||||
*/
|
||||
protected function buildWithFragment(SQLSelect $query, array &$parameters): string
|
||||
{
|
||||
$with = $query->getWith();
|
||||
if (empty($with)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$nl = $this->getSeparator();
|
||||
$clauses = [];
|
||||
|
||||
foreach ($with as $name => $bits) {
|
||||
$clause = $bits['recursive'] ? 'RECURSIVE ' : '';
|
||||
$clause .= Convert::symbol2sql($name);
|
||||
|
||||
if (!empty($bits['cte_fields'])) {
|
||||
$cteFields = $bits['cte_fields'];
|
||||
// Ensure all cte fields are escaped correctly
|
||||
array_walk($cteFields, function (&$colName) {
|
||||
$colName = preg_match('/^".*"$/', $colName) ? $colName : Convert::symbol2sql($colName);
|
||||
});
|
||||
$clause .= ' (' . implode(', ', $cteFields) . ')';
|
||||
}
|
||||
|
||||
$clause .= " AS ({$nl}";
|
||||
$clause .= $this->buildSelectQuery($bits['query'], $parameters);
|
||||
$clause .= "{$nl})";
|
||||
$clauses[] = $clause;
|
||||
}
|
||||
|
||||
return 'WITH ' . implode(",{$nl}", $clauses) . $nl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SELECT clauses ready for inserting into a query.
|
||||
*
|
||||
|
@ -242,9 +289,25 @@ class DBQueryBuilder
|
|||
public function buildFromFragment(SQLConditionalExpression $query, array &$parameters)
|
||||
{
|
||||
$from = $query->getJoins($joinParameters);
|
||||
$tables = [];
|
||||
$joins = [];
|
||||
|
||||
// E.g. a naive "Select 1" statement is valid SQL
|
||||
if (empty($from)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($from as $joinOrTable) {
|
||||
if (preg_match(SQLConditionalExpression::getJoinRegex(), $joinOrTable)) {
|
||||
$joins[] = $joinOrTable;
|
||||
} else {
|
||||
$tables[] = $joinOrTable;
|
||||
}
|
||||
}
|
||||
|
||||
$parameters = array_merge($parameters, $joinParameters);
|
||||
$nl = $this->getSeparator();
|
||||
return "{$nl}FROM " . implode(' ', $from);
|
||||
return "{$nl}FROM " . implode(', ', $tables) . ' ' . implode(' ', $joins);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -269,6 +332,37 @@ class DBQueryBuilder
|
|||
return "{$nl}WHERE (" . implode("){$nl}{$connective} (", $where) . ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the UNION clause(s) ready for inserting into a query.
|
||||
*/
|
||||
protected function buildUnionFragment(SQLSelect $query, array &$parameters): string
|
||||
{
|
||||
$unions = $query->getUnions();
|
||||
if (empty($unions)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$nl = $this->getSeparator();
|
||||
$clauses = [];
|
||||
|
||||
foreach ($unions as $union) {
|
||||
$unionQuery = $union['query'];
|
||||
$unionType = $union['type'];
|
||||
|
||||
$clause = "{$nl}UNION";
|
||||
|
||||
if ($unionType) {
|
||||
$clause .= " $unionType";
|
||||
}
|
||||
|
||||
$clause .= "$nl($nl" . $this->buildSelectQuery($unionQuery, $parameters) . "$nl)";
|
||||
|
||||
$clauses[] = $clause;
|
||||
}
|
||||
|
||||
return implode('', $clauses);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ORDER BY clauses ready for inserting into a query.
|
||||
*
|
||||
|
|
|
@ -635,6 +635,17 @@ abstract class Database
|
|||
$invertedMatch = false
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines if this database supports Common Table Expression (aka WITH) clauses.
|
||||
* By default it is assumed that it doesn't unless this method is explicitly overridden.
|
||||
*
|
||||
* @param bool $recursive if true, checks specifically if recursive CTEs are supported.
|
||||
*/
|
||||
public function supportsCteQueries(bool $recursive = false): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this database supports transactions
|
||||
*
|
||||
|
@ -653,7 +664,6 @@ abstract class Database
|
|||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determines if the used database supports given transactionMode as an argument to startTransaction()
|
||||
* If transactions are completely unsupported, returns false.
|
||||
|
|
|
@ -313,6 +313,41 @@ class MySQLDatabase extends Database implements TransactionManager
|
|||
return $list;
|
||||
}
|
||||
|
||||
public function supportsCteQueries(bool $recursive = false): bool
|
||||
{
|
||||
$version = $this->getVersion();
|
||||
$mariaDBVersion = $this->getMariaDBVersion($version);
|
||||
if ($mariaDBVersion) {
|
||||
// MariaDB has supported CTEs since 10.2.1, and recursive CTEs from 10.2.2
|
||||
// see https://mariadb.com/kb/en/mariadb-1021-release-notes/ and https://mariadb.com/kb/en/mariadb-1022-release-notes/
|
||||
$supportedFrom = $recursive ? '10.2.2' : '10.2.1';
|
||||
return $this->compareVersion($mariaDBVersion, $supportedFrom) >= 0;
|
||||
}
|
||||
// MySQL has supported both kinds of CTEs since 8.0.1
|
||||
// see https://dev.mysql.com/doc/relnotes/mysql/8.0/en/news-8-0-1.html
|
||||
return $this->compareVersion($version, '8.0.1') >= 0;
|
||||
}
|
||||
|
||||
private function getMariaDBVersion(string $version): ?string
|
||||
{
|
||||
// MariaDB versions look like "5.5.5-10.6.8-mariadb-1:10.6.8+maria~focal"
|
||||
// or "10.8.3-MariaDB-1:10.8.3+maria~jammy"
|
||||
// The relevant part is the x.y.z-mariadb portion.
|
||||
if (!preg_match('/((\d+\.){2}\d+)-mariadb/i', $version, $matches)) {
|
||||
return null;
|
||||
}
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
private function compareVersion(string $actualVersion, string $atLeastVersion): int
|
||||
{
|
||||
// Assume it's lower if it's not a proper version number
|
||||
if (!preg_match('/^(\d+\.){2}\d+$/', $actualVersion)) {
|
||||
return -1;
|
||||
}
|
||||
return version_compare($actualVersion, $atLeastVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TransactionManager to handle transactions for this database.
|
||||
*
|
||||
|
|
|
@ -800,6 +800,25 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new DataList instance with a right join clause added to this list's query.
|
||||
*
|
||||
* @param string $table Table name (unquoted and as escaped SQL)
|
||||
* @param string $onClause Escaped SQL statement, e.g. '"Table1"."ID" = "Table2"."ID"'
|
||||
* @param string $alias - if you want this table to be aliased under another name
|
||||
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
|
||||
* will cause the query to appear first. The default is 20, and joins created automatically by the
|
||||
* ORM have a value of 10.
|
||||
* @param array $parameters Any additional parameters if the join is a parameterised subquery
|
||||
* @return static
|
||||
*/
|
||||
public function rightJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
|
||||
{
|
||||
return $this->alterDataQuery(function (DataQuery $query) use ($table, $onClause, $alias, $order, $parameters) {
|
||||
$query->rightJoin($table, $onClause, $alias, $order, $parameters);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of the actual items that this DataList contains at this stage.
|
||||
* This is when the query is actually executed.
|
||||
|
|
|
@ -49,6 +49,11 @@ class DataQuery
|
|||
*/
|
||||
protected $collidingFields = [];
|
||||
|
||||
/**
|
||||
* If true, collisions are allowed for statements aliased as db columns
|
||||
*/
|
||||
private $allowCollidingFieldStatements = false;
|
||||
|
||||
/**
|
||||
* Allows custom callback to be registered before getFinalisedQuery is called.
|
||||
*
|
||||
|
@ -290,6 +295,7 @@ class DataQuery
|
|||
if ($this->collidingFields) {
|
||||
foreach ($this->collidingFields as $collisionField => $collisions) {
|
||||
$caseClauses = [];
|
||||
$lastClauses = [];
|
||||
foreach ($collisions as $collision) {
|
||||
if (preg_match('/^"(?<table>[^"]+)"\./', $collision ?? '', $matches)) {
|
||||
$collisionTable = $matches['table'];
|
||||
|
@ -301,9 +307,14 @@ class DataQuery
|
|||
$caseClauses[] = "WHEN {$collisionClassColumn} IN ({$collisionClassesSQL}) THEN $collision";
|
||||
}
|
||||
} else {
|
||||
user_error("Bad collision item '$collision'", E_USER_WARNING);
|
||||
if ($this->getAllowCollidingFieldStatements()) {
|
||||
$lastClauses[] = "WHEN $collision IS NOT NULL THEN $collision";
|
||||
} else {
|
||||
user_error("Bad collision item '$collision'", E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
$caseClauses = array_merge($caseClauses, $lastClauses);
|
||||
$query->selectField("CASE " . implode(" ", $caseClauses) . " ELSE NULL END", $collisionField);
|
||||
}
|
||||
}
|
||||
|
@ -656,6 +667,20 @@ class DataQuery
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a query to UNION with.
|
||||
*
|
||||
* @param string|null $type One of the SQLSelect::UNION_ALL or SQLSelect::UNION_DISTINCT constants - or null for a default union
|
||||
*/
|
||||
public function union(DataQuery|SQLSelect $query, ?string $type = null): static
|
||||
{
|
||||
if ($query instanceof DataQuery) {
|
||||
$query = $query->query();
|
||||
}
|
||||
$this->query->addUnion($query, $type);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a disjunctive subgroup.
|
||||
*
|
||||
|
@ -676,8 +701,6 @@ class DataQuery
|
|||
return new DataQuery_SubGroup($this, 'OR', $clause);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Create a conjunctive subgroup
|
||||
*
|
||||
|
@ -698,6 +721,39 @@ class DataQuery
|
|||
return new DataQuery_SubGroup($this, 'AND', $clause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a Common Table Expression (CTE), aka WITH clause.
|
||||
*
|
||||
* Use of this method should usually be within a conditional check against DB::get_conn()->supportsCteQueries().
|
||||
*
|
||||
* @param string $name The name of the WITH clause, which can be referenced in any queries UNIONed to the $query
|
||||
* and in this query directly, as though it were a table name.
|
||||
* @param string[] $cteFields Aliases for any columns selected in $query which can be referenced in any queries
|
||||
* UNIONed to the $query and in this query directly, as though they were columns in a real table.
|
||||
* NOTE: If $query is a DataQuery, then cteFields must be the names of real columns on that DataQuery's data class.
|
||||
*/
|
||||
public function with(string $name, DataQuery|SQLSelect $query, array $cteFields = [], bool $recursive = false): static
|
||||
{
|
||||
$schema = DataObject::getSchema();
|
||||
|
||||
// If the query is a DataQuery, make sure all manipulators, joins, etc are applied
|
||||
if ($query instanceof self) {
|
||||
$cteDataClass = $query->dataClass();
|
||||
$query = $query->query();
|
||||
// DataQuery wants to select ALL columns by default,
|
||||
// but if we're setting cteFields then we only want to select those fields.
|
||||
if (!empty($cteFields)) {
|
||||
$selectFields = array_map(fn($colName) => $schema->sqlColumnForField($cteDataClass, $colName), $cteFields);
|
||||
$query->setSelect($selectFields);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the WITH clause
|
||||
$this->query->addWith($name, $query, $cteFields, $recursive);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a WHERE clause.
|
||||
*
|
||||
|
@ -832,6 +888,26 @@ class DataQuery
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a RIGHT JOIN clause to this query.
|
||||
*
|
||||
* @param string $table The unquoted table to join to.
|
||||
* @param string $onClause The filter for the join (escaped SQL statement).
|
||||
* @param string $alias An optional alias name (unquoted)
|
||||
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
|
||||
* will cause the query to appear first. The default is 20, and joins created automatically by the
|
||||
* ORM have a value of 10.
|
||||
* @param array $parameters Any additional parameters if the join is a parameterised subquery
|
||||
* @return $this
|
||||
*/
|
||||
public function rightJoin($table, $onClause, $alias = null, $order = 20, $parameters = [])
|
||||
{
|
||||
if ($table) {
|
||||
$this->query->addRightJoin($table, $onClause, $alias, $order, $parameters);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix of all joined table aliases. E.g. ->filter('Banner.Image.Title)'
|
||||
* Will join the Banner, and then Image relations
|
||||
|
@ -1359,6 +1435,25 @@ class DataQuery
|
|||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether field statements aliased as columns are allowed when that column is already
|
||||
* being selected
|
||||
*/
|
||||
public function getAllowCollidingFieldStatements(): bool
|
||||
{
|
||||
return $this->allowCollidingFieldStatements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether field statements aliased as columns are allowed when that column is already
|
||||
* being selected
|
||||
*/
|
||||
public function setAllowCollidingFieldStatements(bool $value): static
|
||||
{
|
||||
$this->allowCollidingFieldStatements = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function validateColumnField($field, SQLSelect $query)
|
||||
{
|
||||
// standard column - nothing to process here
|
||||
|
|
|
@ -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\DevBuildController;
|
||||
use SilverStripe\Dev\DevelopmentAdmin;
|
||||
use SilverStripe\ORM\Connect\DatabaseException;
|
||||
use SilverStripe\ORM\Connect\TableBuilder;
|
||||
|
@ -61,23 +62,10 @@ class DatabaseAdmin extends Controller
|
|||
{
|
||||
parent::init();
|
||||
|
||||
// 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');
|
||||
$canAccess = (
|
||||
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("ADMIN")
|
||||
);
|
||||
if (!$canAccess) {
|
||||
if (!$this->canInit()) {
|
||||
Security::permissionFailure(
|
||||
$this,
|
||||
"This page is secured and you need administrator rights to access it. " .
|
||||
"This page is secured and you need elevated permissions to access it. " .
|
||||
"Enter your credentials below and we will send you right along."
|
||||
);
|
||||
}
|
||||
|
@ -367,6 +355,23 @@ class DatabaseAdmin extends Controller
|
|||
$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
|
||||
|
|
|
@ -8,7 +8,6 @@ namespace SilverStripe\ORM\Queries;
|
|||
*/
|
||||
abstract class SQLConditionalExpression extends SQLExpression
|
||||
{
|
||||
|
||||
/**
|
||||
* An array of WHERE clauses.
|
||||
*
|
||||
|
@ -136,17 +135,25 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
*/
|
||||
public function addLeftJoin($table, $onPredicate, $tableAlias = '', $order = 20, $parameters = [])
|
||||
{
|
||||
if (!$tableAlias) {
|
||||
$tableAlias = $table;
|
||||
}
|
||||
$this->from[$tableAlias] = [
|
||||
'type' => 'LEFT',
|
||||
'table' => $table,
|
||||
'filter' => [$onPredicate],
|
||||
'order' => $order,
|
||||
'parameters' => $parameters
|
||||
];
|
||||
return $this;
|
||||
return $this->addJoin($table, 'LEFT', $onPredicate, $tableAlias, $order, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a RIGHT JOIN criteria to the tables list.
|
||||
*
|
||||
* @param string $table Unquoted table name
|
||||
* @param string $onPredicate The "ON" SQL fragment in a "RIGHT JOIN ... AS ... ON ..." statement, Needs to be valid
|
||||
* (quoted) SQL.
|
||||
* @param string $tableAlias Optional alias which makes it easier to identify and replace joins later on
|
||||
* @param int $order A numerical index to control the order that joins are added to the query; lower order values
|
||||
* will cause the query to appear first. The default is 20, and joins created automatically by the
|
||||
* ORM have a value of 10.
|
||||
* @param array $parameters Any additional parameters if the join is a parameterized subquery
|
||||
* @return $this Self reference
|
||||
*/
|
||||
public function addRightJoin($table, $onPredicate, $tableAlias = '', $order = 20, $parameters = [])
|
||||
{
|
||||
return $this->addJoin($table, 'RIGHT', $onPredicate, $tableAlias, $order, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -163,12 +170,20 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
* @return $this Self reference
|
||||
*/
|
||||
public function addInnerJoin($table, $onPredicate, $tableAlias = null, $order = 20, $parameters = [])
|
||||
{
|
||||
return $this->addJoin($table, 'INNER', $onPredicate, $tableAlias, $order, $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a JOIN criteria
|
||||
*/
|
||||
private function addJoin($table, $type, $onPredicate, $tableAlias = null, $order = 20, $parameters = []): static
|
||||
{
|
||||
if (!$tableAlias) {
|
||||
$tableAlias = $table;
|
||||
}
|
||||
$this->from[$tableAlias] = [
|
||||
'type' => 'INNER',
|
||||
'type' => $type,
|
||||
'table' => $table,
|
||||
'filter' => [$onPredicate],
|
||||
'order' => $order,
|
||||
|
@ -226,7 +241,7 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
foreach ($this->from as $key => $tableClause) {
|
||||
if (is_array($tableClause)) {
|
||||
$table = '"' . $tableClause['table'] . '"';
|
||||
} elseif (is_string($tableClause) && preg_match('/JOIN +("[^"]+") +(AS|ON) +/i', $tableClause ?? '', $matches)) {
|
||||
} elseif (is_string($tableClause) && preg_match(self::getJoinRegex(), $tableClause ?? '', $matches)) {
|
||||
$table = $matches[1];
|
||||
} else {
|
||||
$table = $tableClause;
|
||||
|
@ -323,11 +338,16 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
return $from;
|
||||
}
|
||||
|
||||
// shift the first FROM table out from so we only deal with the JOINs
|
||||
reset($from);
|
||||
$baseFromAlias = key($from ?? []);
|
||||
$baseFrom = array_shift($from);
|
||||
// Remove the regular FROM tables out so we only deal with the JOINs
|
||||
$regularTables = [];
|
||||
foreach ($from as $alias => $tableClause) {
|
||||
if (is_string($tableClause) && !preg_match(self::getJoinRegex(), $tableClause)) {
|
||||
$regularTables[$alias] = $tableClause;
|
||||
unset($from[$alias]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the joins
|
||||
$this->mergesort($from, function ($firstJoin, $secondJoin) {
|
||||
if (!is_array($firstJoin)
|
||||
|| !is_array($secondJoin)
|
||||
|
@ -339,11 +359,14 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
}
|
||||
});
|
||||
|
||||
// Put the first FROM table back into the results
|
||||
if (!empty($baseFromAlias) && !is_numeric($baseFromAlias)) {
|
||||
$from = array_merge([$baseFromAlias => $baseFrom], $from);
|
||||
} else {
|
||||
array_unshift($from, $baseFrom);
|
||||
// Put the regular FROM tables back into the results
|
||||
$regularTables = array_reverse($regularTables, true);
|
||||
foreach ($regularTables as $alias => $tableName) {
|
||||
if (!empty($alias) && !is_numeric($alias)) {
|
||||
$from = array_merge([$alias => $tableName], $from);
|
||||
} else {
|
||||
array_unshift($from, $tableName);
|
||||
}
|
||||
}
|
||||
|
||||
return $from;
|
||||
|
@ -766,4 +789,12 @@ abstract class SQLConditionalExpression extends SQLExpression
|
|||
$this->copyTo($update);
|
||||
return $update;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regular expression pattern used to identify JOIN statements
|
||||
*/
|
||||
public static function getJoinRegex(): string
|
||||
{
|
||||
return '/JOIN\s+.*?\s+(AS|ON|USING\(?)\s+/is';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Queries;
|
|||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\ORM\DB;
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* Object representing a SQL SELECT query.
|
||||
|
@ -12,6 +13,9 @@ use InvalidArgumentException;
|
|||
*/
|
||||
class SQLSelect extends SQLConditionalExpression
|
||||
{
|
||||
public const UNION_ALL = 'ALL';
|
||||
|
||||
public const UNION_DISTINCT = 'DISTINCT';
|
||||
|
||||
/**
|
||||
* An array of SELECT fields, keyed by an optional alias.
|
||||
|
@ -36,6 +40,23 @@ class SQLSelect extends SQLConditionalExpression
|
|||
*/
|
||||
protected $having = [];
|
||||
|
||||
/**
|
||||
* An array of subqueries to union with this one.
|
||||
*/
|
||||
protected array $union = [];
|
||||
|
||||
/**
|
||||
* An array of WITH clauses.
|
||||
* This array is indexed with the name for the temporary table generated for the WITH clause,
|
||||
* and contains data in the following format:
|
||||
* [
|
||||
* 'cte_fields' => string[],
|
||||
* 'query' => SQLSelect|null,
|
||||
* 'recursive' => boolean,
|
||||
* ]
|
||||
*/
|
||||
protected array $with = [];
|
||||
|
||||
/**
|
||||
* If this is true DISTINCT will be added to the SQL.
|
||||
*
|
||||
|
@ -162,6 +183,9 @@ class SQLSelect extends SQLConditionalExpression
|
|||
$fields = [$fields];
|
||||
}
|
||||
foreach ($fields as $idx => $field) {
|
||||
if ($field === '') {
|
||||
continue;
|
||||
}
|
||||
$this->selectField($field, is_numeric($idx) ? null : $idx);
|
||||
}
|
||||
|
||||
|
@ -529,6 +553,60 @@ class SQLSelect extends SQLConditionalExpression
|
|||
return $conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a select query to UNION with.
|
||||
*
|
||||
* @param string|null $type One of the UNION_ALL or UNION_DISTINCT constants - or null for a default union
|
||||
*/
|
||||
public function addUnion(SQLSelect $query, ?string $type = null): static
|
||||
{
|
||||
if ($type && $type !== self::UNION_ALL && $type !== self::UNION_DISTINCT) {
|
||||
throw new LogicException('Union $type must be one of the constants UNION_ALL or UNION_DISTINCT.');
|
||||
}
|
||||
|
||||
$this->union[] = ['query' => $query, 'type' => $type];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the queries that will be UNIONed with this one.
|
||||
*/
|
||||
public function getUnions(): array
|
||||
{
|
||||
return $this->union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a Common Table Expression (CTE), aka WITH clause.
|
||||
*
|
||||
* Use of this method should usually be within a conditional check against DB::get_conn()->supportsCteQueries().
|
||||
*
|
||||
* @param string $name The name of the WITH clause, which can be referenced in any queries UNIONed to the $query
|
||||
* and in this query directly, as though it were a table name.
|
||||
* @param string[] $cteFields Aliases for any columns selected in $query which can be referenced in any queries
|
||||
* UNIONed to the $query and in this query directly, as though they were columns in a real table.
|
||||
*/
|
||||
public function addWith(string $name, SQLSelect $query, array $cteFields = [], bool $recursive = false): static
|
||||
{
|
||||
if (array_key_exists($name, $this->with)) {
|
||||
throw new LogicException("WITH clause with name '$name' already exists.");
|
||||
}
|
||||
$this->with[$name] = [
|
||||
'cte_fields' => $cteFields,
|
||||
'query' => $query,
|
||||
'recursive' => $recursive,
|
||||
];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data which will be used to generate the WITH clause of the query
|
||||
*/
|
||||
public function getWith(): array
|
||||
{
|
||||
return $this->with;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of GROUP BY clauses used internally.
|
||||
*
|
||||
|
@ -712,4 +790,10 @@ class SQLSelect extends SQLConditionalExpression
|
|||
$query->setLimit(1, $index);
|
||||
return $query;
|
||||
}
|
||||
|
||||
public function isEmpty()
|
||||
{
|
||||
// Empty if there's no select, or we're trying to select '*' but there's no FROM clause
|
||||
return empty($this->select) || (empty($this->from) && array_key_exists('*', $this->select));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ use SilverStripe\ORM\UnsavedRelationList;
|
|||
use SilverStripe\ORM\ValidationException;
|
||||
use SilverStripe\ORM\ValidationResult;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Closure;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The member class which represents the users of the system
|
||||
|
@ -665,7 +667,13 @@ class Member extends DataObject
|
|||
$password->setRequireExistingPassword(true);
|
||||
}
|
||||
|
||||
$password->setCanBeEmpty(false);
|
||||
if (!$editingPassword) {
|
||||
$password->setCanBeEmpty(true);
|
||||
$password->setRandomPasswordCallback(Closure::fromCallable([$this, 'generateRandomPassword']));
|
||||
// explicitly set "require strong password" to false because its regex in ConfirmedPasswordField
|
||||
// is too restrictive for generateRandomPassword() which will add in non-alphanumeric characters
|
||||
$password->setRequireStrongPassword(false);
|
||||
}
|
||||
$this->extend('updateMemberPasswordField', $password);
|
||||
|
||||
return $password;
|
||||
|
@ -1695,4 +1703,51 @@ class Member extends DataObject
|
|||
// If can't find a suitable editor, just default to cms
|
||||
return $currentName ? $currentName : 'cms';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password and validate it against the current password validator if one is set
|
||||
*
|
||||
* @param int $length The length of the password to generate, defaults to 0 which will use the
|
||||
* greater of the validator's minimum length or 20
|
||||
*/
|
||||
public function generateRandomPassword(int $length = 0): string
|
||||
{
|
||||
$password = '';
|
||||
$validator = self::password_validator();
|
||||
if ($length && $validator && $length < $validator->getMinLength()) {
|
||||
throw new InvalidArgumentException('length argument is less than password validator minLength');
|
||||
}
|
||||
$validatorMinLength = $validator ? $validator->getMinLength() : 0;
|
||||
$len = $length ?: max($validatorMinLength, 20);
|
||||
// The default PasswordValidator checks the password includes the following four character sets
|
||||
$charsets = [
|
||||
'abcdefghijklmnopqrstuvwyxz',
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWYXZ',
|
||||
'0123456789',
|
||||
'!@#$%^&*()_+-=[]{};:,./<>?',
|
||||
];
|
||||
$password = '';
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$charset = $charsets[$i % 4];
|
||||
$randomInt = random_int(0, strlen($charset) - 1);
|
||||
$password .= $charset[$randomInt];
|
||||
}
|
||||
// randomise the order of the characters
|
||||
$passwordArr = [];
|
||||
$len = strlen($password);
|
||||
foreach (str_split($password) as $char) {
|
||||
$r = random_int(0, $len + 10000);
|
||||
while (array_key_exists($r, $passwordArr)) {
|
||||
$r++;
|
||||
}
|
||||
$passwordArr[$r] = $char;
|
||||
}
|
||||
ksort($passwordArr);
|
||||
$password = implode('', $passwordArr);
|
||||
$this->extend('updateRandomPassword', $password);
|
||||
if ($validator && !$validator->validate($password, $this)) {
|
||||
throw new RuntimeException('Unable to generate a random password');
|
||||
}
|
||||
return $password;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,15 +2,28 @@
|
|||
|
||||
namespace SilverStripe\Security;
|
||||
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
|
||||
/**
|
||||
* Legacy implementation for SilverStripe 2.1 - 2.3,
|
||||
* which had a design flaw in password hashing that caused
|
||||
* the hashes to differ between architectures due to
|
||||
* floating point precision problems in base_convert().
|
||||
* See http://open.silverstripe.org/ticket/3004
|
||||
*
|
||||
* @deprecated 5.2.0 Use SilverStripe\Security\PasswordEncryptor_PHPHash instead.
|
||||
*/
|
||||
class PasswordEncryptor_LegacyPHPHash extends PasswordEncryptor_PHPHash
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Deprecation::notice(
|
||||
'5.2.0',
|
||||
'Use SilverStripe\Security\PasswordEncryptor_PHPHash instead.',
|
||||
Deprecation::SCOPE_CLASS
|
||||
);
|
||||
}
|
||||
|
||||
public function encrypt($password, $salt = null, $member = null)
|
||||
{
|
||||
$password = parent::encrypt($password, $salt, $member);
|
||||
|
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
namespace SilverStripe\Security;
|
||||
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\ORM\DB;
|
||||
|
||||
/**
|
||||
* Uses MySQL's OLD_PASSWORD encyrption. Requires an active DB connection.
|
||||
*
|
||||
* @deprecated 5.2.0 Use another subclass of SilverStripe\Security\PasswordEncryptor instead.
|
||||
*/
|
||||
class PasswordEncryptor_MySQLOldPassword extends PasswordEncryptor
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Deprecation::notice(
|
||||
'5.2.0',
|
||||
'Use another subclass of SilverStripe\Security\PasswordEncryptor instead.',
|
||||
Deprecation::SCOPE_CLASS
|
||||
);
|
||||
}
|
||||
|
||||
public function encrypt($password, $salt = null, $member = null)
|
||||
{
|
||||
return DB::prepared_query("SELECT OLD_PASSWORD(?)", [$password])->value();
|
||||
|
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
namespace SilverStripe\Security;
|
||||
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\ORM\DB;
|
||||
|
||||
/**
|
||||
* Uses MySQL's PASSWORD encryption. Requires an active DB connection.
|
||||
*
|
||||
* @deprecated 5.2.0 Use another subclass of SilverStripe\Security\PasswordEncryptor instead.
|
||||
*/
|
||||
class PasswordEncryptor_MySQLPassword extends PasswordEncryptor
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Deprecation::notice(
|
||||
'5.2.0',
|
||||
'Use another subclass of SilverStripe\Security\PasswordEncryptor instead.',
|
||||
Deprecation::SCOPE_CLASS
|
||||
);
|
||||
}
|
||||
|
||||
public function encrypt($password, $salt = null, $member = null)
|
||||
{
|
||||
return DB::prepared_query("SELECT PASSWORD(?)", [$password])->value();
|
||||
|
|
|
@ -2,13 +2,25 @@
|
|||
|
||||
namespace SilverStripe\Security;
|
||||
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
|
||||
/**
|
||||
* Cleartext passwords (used in SilverStripe 2.1).
|
||||
* Also used when Security::$encryptPasswords is set to FALSE.
|
||||
* Not recommended.
|
||||
*
|
||||
* @deprecated 5.2.0 Use another subclass of SilverStripe\Security\PasswordEncryptor instead.
|
||||
*/
|
||||
class PasswordEncryptor_None extends PasswordEncryptor
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Deprecation::notice(
|
||||
'5.2.0',
|
||||
'Use another subclass of SilverStripe\Security\PasswordEncryptor instead.',
|
||||
Deprecation::SCOPE_CLASS
|
||||
);
|
||||
}
|
||||
|
||||
public function encrypt($password, $salt = null, $member = null)
|
||||
{
|
||||
return $password;
|
||||
|
|
|
@ -4,12 +4,14 @@ namespace SilverStripe\Dev\Tests;
|
|||
|
||||
use PHPUnit\Framework\Error\Deprecated;
|
||||
use ReflectionMethod;
|
||||
use ReflectionProperty;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Core\Environment;
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Dev\Tests\DeprecationTest\DeprecationTestObject;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\Core\Kernel;
|
||||
|
||||
class DeprecationTest extends SapphireTest
|
||||
{
|
||||
|
@ -445,4 +447,104 @@ class DeprecationTest extends SapphireTest
|
|||
|
||||
$this->assertSame($expected, $reflectionVarAsBoolean->invoke(null, $rawValue));
|
||||
}
|
||||
|
||||
public function provideIsEnabled()
|
||||
{
|
||||
return [
|
||||
'dev, explicitly enabled' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => true,
|
||||
'staticEnabled' => true,
|
||||
'expected' => true,
|
||||
],
|
||||
'dev, explicitly enabled override static' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => true,
|
||||
'staticEnabled' => false,
|
||||
'expected' => true,
|
||||
],
|
||||
'dev, explicitly disabled override static' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => false,
|
||||
'staticEnabled' => true,
|
||||
'expected' => false,
|
||||
],
|
||||
'dev, explicitly disabled' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => false,
|
||||
'staticEnabled' => false,
|
||||
'expected' => false,
|
||||
],
|
||||
'dev, statically disabled' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => null,
|
||||
'staticEnabled' => true,
|
||||
'expected' => true,
|
||||
],
|
||||
'dev, statically disabled' => [
|
||||
'envMode' => 'dev',
|
||||
'envEnabled' => null,
|
||||
'staticEnabled' => false,
|
||||
'expected' => false,
|
||||
],
|
||||
'live, explicitly enabled' => [
|
||||
'envMode' => 'live',
|
||||
'envEnabled' => true,
|
||||
'staticEnabled' => true,
|
||||
'expected' => false,
|
||||
],
|
||||
'live, explicitly disabled' => [
|
||||
'envMode' => 'live',
|
||||
'envEnabled' => false,
|
||||
'staticEnabled' => false,
|
||||
'expected' => false,
|
||||
],
|
||||
'live, statically disabled' => [
|
||||
'envMode' => 'live',
|
||||
'envEnabled' => null,
|
||||
'staticEnabled' => true,
|
||||
'expected' => false,
|
||||
],
|
||||
'live, statically disabled' => [
|
||||
'envMode' => 'live',
|
||||
'envEnabled' => null,
|
||||
'staticEnabled' => false,
|
||||
'expected' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideIsEnabled
|
||||
*/
|
||||
public function testIsEnabled(string $envMode, ?bool $envEnabled, bool $staticEnabled, bool $expected)
|
||||
{
|
||||
/** @var Kernel $kernel */
|
||||
$kernel = Injector::inst()->get(Kernel::class);
|
||||
$origMode = $kernel->getEnvironment();
|
||||
$origEnvEnabled = Environment::getEnv('SS_DEPRECATION_ENABLED');
|
||||
$reflectionEnabled = new ReflectionProperty(Deprecation::class, 'currentlyEnabled');
|
||||
$reflectionEnabled->setAccessible(true);
|
||||
$origStaticEnabled = $reflectionEnabled->getValue();
|
||||
|
||||
try {
|
||||
$kernel->setEnvironment($envMode);
|
||||
Environment::setEnv('SS_DEPRECATION_ENABLED', $envEnabled);
|
||||
$this->setEnabledViaStatic($staticEnabled);
|
||||
$this->assertSame($expected, Deprecation::isEnabled());
|
||||
} finally {
|
||||
$kernel->setEnvironment($origMode);
|
||||
Environment::setEnv('SS_DEPRECATION_ENABLED', $origEnvEnabled);
|
||||
$this->setEnabledViaStatic($origStaticEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
private function setEnabledViaStatic(bool $enabled): void
|
||||
{
|
||||
if ($enabled) {
|
||||
Deprecation::enable();
|
||||
} else {
|
||||
Deprecation::disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,11 +2,15 @@
|
|||
|
||||
namespace SilverStripe\Dev\Tests;
|
||||
|
||||
use Exception;
|
||||
use ReflectionMethod;
|
||||
use SilverStripe\Control\Director;
|
||||
use SilverStripe\Core\Injector\Injector;
|
||||
use SilverStripe\Core\Kernel;
|
||||
use SilverStripe\Dev\DevelopmentAdmin;
|
||||
use SilverStripe\Dev\FunctionalTest;
|
||||
use SilverStripe\Control\Director;
|
||||
use Exception;
|
||||
use SilverStripe\Dev\Tests\DevAdminControllerTest\Controller1;
|
||||
use SilverStripe\Dev\Tests\DevAdminControllerTest\ControllerWithPermissions;
|
||||
|
||||
/**
|
||||
* Note: the running of this test is handled by the thing it's testing (DevelopmentAdmin controller).
|
||||
|
@ -21,24 +25,29 @@ class DevAdminControllerTest extends FunctionalTest
|
|||
DevelopmentAdmin::config()->merge(
|
||||
'registered_controllers',
|
||||
[
|
||||
'x1' => [
|
||||
'controller' => Controller1::class,
|
||||
'links' => [
|
||||
'x1' => 'x1 link description',
|
||||
'x1/y1' => 'x1/y1 link description'
|
||||
]
|
||||
],
|
||||
'x2' => [
|
||||
'controller' => 'DevAdminControllerTest_Controller2', // intentionally not a class that exists
|
||||
'links' => [
|
||||
'x2' => 'x2 link description'
|
||||
]
|
||||
]
|
||||
'x1' => [
|
||||
'controller' => Controller1::class,
|
||||
'links' => [
|
||||
'x1' => 'x1 link description',
|
||||
'x1/y1' => 'x1/y1 link description'
|
||||
]
|
||||
],
|
||||
'x2' => [
|
||||
'controller' => 'DevAdminControllerTest_Controller2', // intentionally not a class that exists
|
||||
'links' => [
|
||||
'x2' => 'x2 link description'
|
||||
]
|
||||
],
|
||||
'x3' => [
|
||||
'controller' => ControllerWithPermissions::class,
|
||||
'links' => [
|
||||
'x3' => 'x3 link description'
|
||||
]
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function testGoodRegisteredControllerOutput()
|
||||
{
|
||||
// Check for the controller running from the registered url above
|
||||
|
@ -57,7 +66,43 @@ class DevAdminControllerTest extends FunctionalTest
|
|||
$this->assertEquals(true, $this->getAndCheckForError('/dev/x2'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getLinksPermissionsProvider
|
||||
*/
|
||||
public function testGetLinks(string $permission, array $present, array $absent): void
|
||||
{
|
||||
DevelopmentAdmin::config()->set('allow_all_cli', false);
|
||||
$kernel = Injector::inst()->get(Kernel::class);
|
||||
$env = $kernel->getEnvironment();
|
||||
$kernel->setEnvironment(Kernel::LIVE);
|
||||
try {
|
||||
$this->logInWithPermission($permission);
|
||||
$controller = new DevelopmentAdmin();
|
||||
$method = new ReflectionMethod($controller, 'getLinks');
|
||||
$method->setAccessible(true);
|
||||
$links = $method->invoke($controller);
|
||||
|
||||
foreach ($present as $expected) {
|
||||
$this->assertArrayHasKey($expected, $links, sprintf('Expected link %s not found in %s', $expected, json_encode($links)));
|
||||
}
|
||||
|
||||
foreach ($absent as $unexpected) {
|
||||
$this->assertArrayNotHasKey($unexpected, $links, sprintf('Unexpected link %s found in %s', $unexpected, json_encode($links)));
|
||||
}
|
||||
} finally {
|
||||
$kernel->setEnvironment($env);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getLinksPermissionsProvider() : array
|
||||
{
|
||||
return [
|
||||
['ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']],
|
||||
['ALL_DEV_ADMIN', ['x1', 'x1/y1', 'x3'], ['x2']],
|
||||
['DEV_ADMIN_TEST_PERMISSION', ['x3'], ['x1', 'x1/y1', 'x2']],
|
||||
['NOTHING', [], ['x1', 'x1/y1', 'x2', 'x3']],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getCapture($url)
|
||||
{
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\Dev\Tests\DevAdminControllerTest;
|
||||
|
||||
use SilverStripe\Control\Controller;
|
||||
use SilverStripe\Security\Permission;
|
||||
use SilverStripe\Security\PermissionProvider;
|
||||
|
||||
class ControllerWithPermissions extends Controller implements PermissionProvider
|
||||
{
|
||||
|
||||
public const OK_MSG = 'DevAdminControllerTest_ControllerWithPermissions TEST OK';
|
||||
|
||||
private static $url_handlers = [
|
||||
'' => 'index',
|
||||
];
|
||||
|
||||
private static $allowed_actions = [
|
||||
'index',
|
||||
];
|
||||
|
||||
|
||||
public function index()
|
||||
{
|
||||
echo self::OK_MSG;
|
||||
}
|
||||
|
||||
public function canInit()
|
||||
{
|
||||
return Permission::check('DEV_ADMIN_TEST_PERMISSION');
|
||||
}
|
||||
|
||||
public function providePermissions()
|
||||
{
|
||||
return [
|
||||
'DEV_ADMIN_TEST_PERMISSION' => 'Dev admin test permission',
|
||||
];
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ use SilverStripe\Forms\ReadonlyField;
|
|||
use SilverStripe\Forms\RequiredFields;
|
||||
use SilverStripe\Security\Member;
|
||||
use SilverStripe\Security\PasswordValidator;
|
||||
use Closure;
|
||||
|
||||
class ConfirmedPasswordFieldTest extends SapphireTest
|
||||
{
|
||||
|
@ -381,4 +382,49 @@ class ConfirmedPasswordFieldTest extends SapphireTest
|
|||
$field->setRequireExistingPassword(false);
|
||||
$this->assertCount(2, $field->getChildren(), 'Current password field should not be removed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSetCanBeEmptySaveInto
|
||||
*/
|
||||
public function testSetCanBeEmptySaveInto(bool $generateRandomPasswordOnEmpty, ?string $expected)
|
||||
{
|
||||
$field = new ConfirmedPasswordField('Test', 'Change it');
|
||||
$field->setCanBeEmpty(true);
|
||||
if ($generateRandomPasswordOnEmpty) {
|
||||
$field->setRandomPasswordCallback(Closure::fromCallable(function () {
|
||||
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
|
||||
}));
|
||||
}
|
||||
$this->assertEmpty($field->Value());
|
||||
$member = new Member();
|
||||
$field->saveInto($member);
|
||||
$this->assertSame($expected, $field->Value());
|
||||
}
|
||||
|
||||
public function provideSetCanBeEmptySaveInto(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'generateRandomPasswordOnEmpty' => true,
|
||||
'expected' => 'R4ndom-P4ssw0rd$LOREM^ipsum#12345',
|
||||
],
|
||||
[
|
||||
'generateRandomPasswordOnEmpty' => false,
|
||||
'expected' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testSetCanBeEmptyRightTitle()
|
||||
{
|
||||
$field = new ConfirmedPasswordField('Test', 'Change it');
|
||||
$passwordField = $field->getPasswordField();
|
||||
$this->assertEmpty($passwordField->RightTitle());
|
||||
$field->setCanBeEmpty(true);
|
||||
$this->assertEmpty($passwordField->RightTitle());
|
||||
$field->setRandomPasswordCallback(Closure::fromCallable(function () {
|
||||
return 'R4ndom-P4ssw0rd$LOREM^ipsum#12345';
|
||||
}));
|
||||
$this->assertNotEmpty($passwordField->RightTitle());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ use SilverStripe\Forms\TextField;
|
|||
use SilverStripe\Forms\RequiredFields;
|
||||
use SilverStripe\Forms\FormAction;
|
||||
use SilverStripe\Forms\PopoverField;
|
||||
use SilverStripe\Forms\FormField;
|
||||
use LogicException;
|
||||
|
||||
class FormSchemaTest extends SapphireTest
|
||||
{
|
||||
|
@ -39,6 +41,49 @@ class FormSchemaTest extends SapphireTest
|
|||
$this->assertEquals($expected, $schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideGetSchemaException
|
||||
*/
|
||||
public function testGetSchemaException(string $field, bool $expectException): void
|
||||
{
|
||||
$fields = [];
|
||||
if ($field === '<HasComponent>') {
|
||||
$fields[] = (new FormField('TestField'))->setSchemaComponent('MyPretendComponent');
|
||||
} elseif ($field === '<HasDataType>') {
|
||||
$fields[] = new class('TestField') extends FormField {
|
||||
protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_CUSTOM;
|
||||
};
|
||||
} elseif ($field === '<None>') {
|
||||
$fields[] = new FormField('TestField');
|
||||
}
|
||||
$form = new Form(null, 'TestForm', new FieldList($fields));
|
||||
$formSchema = new FormSchema($form);
|
||||
if ($expectException) {
|
||||
$this->expectException(LogicException::class);
|
||||
} else {
|
||||
$this->expectNotToPerformAssertions();
|
||||
}
|
||||
$formSchema->getSchema($form);
|
||||
}
|
||||
|
||||
public function provideGetSchemaException(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'field' => '<HasComponent>',
|
||||
'expectException' => false,
|
||||
],
|
||||
[
|
||||
'field' => '<HasDataType>',
|
||||
'expectException' => false,
|
||||
],
|
||||
[
|
||||
'field' => '<None>',
|
||||
'expectException' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testGetState()
|
||||
{
|
||||
$form = new Form(null, 'TestForm', new FieldList(), new FieldList());
|
||||
|
|
|
@ -76,6 +76,73 @@ class FormTest extends FunctionalTest
|
|||
];
|
||||
}
|
||||
|
||||
public function formMessageDataProvider()
|
||||
{
|
||||
return [
|
||||
[
|
||||
[
|
||||
'Just a string',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Just a string',
|
||||
'Certainly different',
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
'Just a string',
|
||||
'Certainly different',
|
||||
'Just a string',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function formMessageExceptionsDataProvider()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'message_1' => [
|
||||
'val' => 'Just a string',
|
||||
'type' => ValidationResult::TYPE_ERROR,
|
||||
'cast' => ValidationResult::CAST_TEXT,
|
||||
],
|
||||
'message_2' => [
|
||||
'val' => 'This is a good message',
|
||||
'type' => ValidationResult::TYPE_GOOD,
|
||||
'cast' => ValidationResult::CAST_TEXT,
|
||||
],
|
||||
],
|
||||
[
|
||||
'message_1' => [
|
||||
'val' => 'This is a good message',
|
||||
'type' => ValidationResult::TYPE_GOOD,
|
||||
'cast' => ValidationResult::CAST_TEXT,
|
||||
],
|
||||
'message_2' => [
|
||||
'val' => 'HTML is the future of the web',
|
||||
'type' => ValidationResult::TYPE_GOOD,
|
||||
'cast' => ValidationResult::CAST_HTML,
|
||||
],
|
||||
],
|
||||
[
|
||||
'message_1' => [
|
||||
'val' => 'This is a good message',
|
||||
'type' => ValidationResult::TYPE_GOOD,
|
||||
'cast' => ValidationResult::CAST_TEXT,
|
||||
],
|
||||
'message_2' => [
|
||||
'val' => 'HTML is the future of the web',
|
||||
'type' => ValidationResult::TYPE_GOOD,
|
||||
'cast' => ValidationResult::CAST_HTML,
|
||||
],
|
||||
'force' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testLoadDataFromRequest()
|
||||
{
|
||||
$form = new Form(
|
||||
|
@ -1030,6 +1097,54 @@ class FormTest extends FunctionalTest
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider formMessageDataProvider
|
||||
*/
|
||||
public function testFieldMessageAppend($messages)
|
||||
{
|
||||
$form = $this->getStubForm();
|
||||
foreach ($messages as $message) {
|
||||
$form->appendMessage($message);
|
||||
}
|
||||
$parser = new CSSContentParser($form->forTemplate());
|
||||
$messageEls = $parser->getBySelector('.message');
|
||||
|
||||
foreach ($messages as $message) {
|
||||
$this->assertStringContainsString($message, $messageEls[0]->asXML());
|
||||
$this->assertEquals(1, substr_count($messageEls[0]->asXML(), $message), 'Should not append if already present');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider formMessageExceptionsDataProvider
|
||||
*/
|
||||
public function testFieldMessageAppendExceptions(array $message1, array $message2, bool $force = false)
|
||||
{
|
||||
$form = $this->getStubForm();
|
||||
$form->appendMessage($message1['val'], $message1['type'], $message1['cast']);
|
||||
if (!$force) {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage(
|
||||
sprintf(
|
||||
"Couldn't append message of type %s and cast %s to existing message of type %s and cast %s",
|
||||
$message2['type'],
|
||||
$message2['cast'],
|
||||
$message1['type'],
|
||||
$message1['cast'],
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$form->appendMessage($message2['val'], $message2['type'], $message2['cast'], $force);
|
||||
|
||||
if ($force) {
|
||||
$parser = new CSSContentParser($form->forTemplate());
|
||||
$messageEls = $parser->getBySelector('.message');
|
||||
$this->assertStringContainsString($message2['val'], $messageEls[0]->asXML());
|
||||
$this->assertStringNotContainsString($message1['val'], $messageEls[0]->asXML());
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetExtraFields()
|
||||
{
|
||||
$form = new FormTest\ExtraFieldsForm(
|
||||
|
|
|
@ -4,10 +4,12 @@ namespace SilverStripe\Forms\Tests;
|
|||
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Forms\RequiredFields;
|
||||
use SilverStripe\Forms\Form;
|
||||
use SilverStripe\Forms\TreeDropdownField;
|
||||
use SilverStripe\Security\Group;
|
||||
|
||||
class RequiredFieldsTest extends SapphireTest
|
||||
{
|
||||
|
||||
public function testConstructingWithArray()
|
||||
{
|
||||
//can we construct with an array?
|
||||
|
@ -286,4 +288,18 @@ class RequiredFieldsTest extends SapphireTest
|
|||
"Unexpectedly returned true for a non-existent field"
|
||||
);
|
||||
}
|
||||
|
||||
public function testTreedropFieldValidation()
|
||||
{
|
||||
$form = new Form();
|
||||
$field = new TreeDropdownField('TestField', 'TestField', Group::class);
|
||||
$form->Fields()->push($field);
|
||||
$validator = new RequiredFields('TestField');
|
||||
$validator->setForm($form);
|
||||
// blank string and '0' are fail required field validation
|
||||
$this->assertFalse($validator->php(['TestField' => '']));
|
||||
$this->assertFalse($validator->php(['TestField' => '0']));
|
||||
// '1' passes required field validation
|
||||
$this->assertTrue($validator->php(['TestField' => '1']));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ use SilverStripe\Dev\SapphireTest;
|
|||
use SilverStripe\Control\HTTPRequest;
|
||||
use SilverStripe\Forms\FieldList;
|
||||
use SilverStripe\Forms\Form;
|
||||
use SilverStripe\Forms\RequiredFields;
|
||||
use SilverStripe\Forms\TreeDropdownField;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\Tests\HierarchyTest\HierarchyOnSubclassTestObject;
|
||||
|
@ -52,6 +53,22 @@ class TreeDropdownFieldTest extends SapphireTest
|
|||
);
|
||||
}
|
||||
|
||||
public function testGetSchemaValidation(): void
|
||||
{
|
||||
// field is not required
|
||||
$field = new TreeDropdownField('TestTree', 'Test tree', Folder::class);
|
||||
$expected = [];
|
||||
$this->assertSame($expected, $field->getSchemaValidation());
|
||||
// field is required
|
||||
$fieldList = new FieldList([$field]);
|
||||
$validator = new RequiredFields('TestTree');
|
||||
new Form(null, null, $fieldList, null, $validator);
|
||||
$expected = [
|
||||
'required' => ['extraEmptyValues' => ['0']],
|
||||
];
|
||||
$this->assertSame($expected, $field->getSchemaValidation());
|
||||
}
|
||||
|
||||
public function testTreeSearchJson()
|
||||
{
|
||||
$field = new TreeDropdownField('TestTree', 'Test tree', Folder::class);
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests;
|
||||
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\ORM\Connect\DBQueryBuilder;
|
||||
use SilverStripe\ORM\Queries\SQLSelect;
|
||||
|
||||
class DBQueryBuilderTest extends SapphireTest
|
||||
{
|
||||
protected $usesDatabase = false;
|
||||
|
||||
public function testMultilineJoin()
|
||||
{
|
||||
$join = <<<JOIN
|
||||
INNER JOIN
|
||||
(SELECT DISTINCT "SiteTreeLink"."ClassName", "SiteTreeLink"."LastEdited", "SiteTreeLink"."Created", "SiteTreeLink"."LinkedID",
|
||||
"SiteTreeLink"."ParentID", "SiteTreeLink"."ParentClass", "SiteTreeLink"."ID" FROM "SiteTreeLink")
|
||||
AS "SiteTreeLink" ON "SiteTreeLink"."LinkedID" = "SiteTree"."ID"
|
||||
JOIN;
|
||||
$select = new SQLSelect('*', ['SomeTable', $join]);
|
||||
$builder = new DBQueryBuilder();
|
||||
|
||||
$params = [];
|
||||
$this->assertSame('FROM SomeTable ' . $join, trim($builder->buildFromFragment($select, $params)));
|
||||
}
|
||||
}
|
|
@ -304,12 +304,33 @@ class DataListTest extends SapphireTest
|
|||
$this->assertSQLEquals($expected, $list->sql($parameters));
|
||||
}
|
||||
|
||||
public function testInnerJoin()
|
||||
public function provideJoin()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'joinMethod' => 'innerJoin',
|
||||
'joinType' => 'INNER JOIN',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'leftJoin',
|
||||
'joinType' => 'LEFT JOIN',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'rightJoin',
|
||||
'joinType' => 'RIGHT JOIN',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideJoin
|
||||
*/
|
||||
public function testJoin(string $joinMethod, string $joinType)
|
||||
{
|
||||
$db = DB::get_conn();
|
||||
|
||||
$list = TeamComment::get();
|
||||
$list = $list->innerJoin(
|
||||
$list = $list->$joinMethod(
|
||||
'DataObjectTest_Team',
|
||||
'"DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"',
|
||||
'Team'
|
||||
|
@ -322,22 +343,24 @@ class DataListTest extends SapphireTest
|
|||
. 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL'
|
||||
. ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '
|
||||
. $db->quoteString(DataObjectTest\TeamComment::class)
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" INNER JOIN '
|
||||
. '"DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = '
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" ' . $joinType
|
||||
. ' "DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = '
|
||||
. '"DataObjectTest_TeamComment"."TeamID"'
|
||||
. ' ORDER BY "DataObjectTest_TeamComment"."Name" ASC';
|
||||
|
||||
|
||||
$this->assertSQLEquals($expected, $list->sql($parameters));
|
||||
$this->assertEmpty($parameters);
|
||||
}
|
||||
|
||||
public function testInnerJoinParameterised()
|
||||
/**
|
||||
* @dataProvider provideJoin
|
||||
*/
|
||||
public function testJoinParameterised(string $joinMethod, string $joinType)
|
||||
{
|
||||
$db = DB::get_conn();
|
||||
|
||||
$list = TeamComment::get();
|
||||
$list = $list->innerJoin(
|
||||
$list = $list->$joinMethod(
|
||||
'DataObjectTest_Team',
|
||||
'"DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID" '
|
||||
. 'AND "DataObjectTest_Team"."Title" LIKE ?',
|
||||
|
@ -353,66 +376,8 @@ class DataListTest extends SapphireTest
|
|||
. 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL'
|
||||
. ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '
|
||||
. $db->quoteString(DataObjectTest\TeamComment::class)
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" INNER JOIN '
|
||||
. '"DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = '
|
||||
. '"DataObjectTest_TeamComment"."TeamID" '
|
||||
. 'AND "DataObjectTest_Team"."Title" LIKE ?'
|
||||
. ' ORDER BY "DataObjectTest_TeamComment"."Name" ASC';
|
||||
|
||||
$this->assertSQLEquals($expected, $list->sql($parameters));
|
||||
$this->assertEquals(['Team%'], $parameters);
|
||||
}
|
||||
|
||||
public function testLeftJoin()
|
||||
{
|
||||
$db = DB::get_conn();
|
||||
|
||||
$list = TeamComment::get();
|
||||
$list = $list->leftJoin(
|
||||
'DataObjectTest_Team',
|
||||
'"DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"',
|
||||
'Team'
|
||||
);
|
||||
|
||||
$expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", '
|
||||
. '"DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Created", '
|
||||
. '"DataObjectTest_TeamComment"."Name", "DataObjectTest_TeamComment"."Comment", '
|
||||
. '"DataObjectTest_TeamComment"."TeamID", "DataObjectTest_TeamComment"."ID", '
|
||||
. 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL '
|
||||
. 'THEN "DataObjectTest_TeamComment"."ClassName" ELSE '
|
||||
. $db->quoteString(DataObjectTest\TeamComment::class)
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" LEFT JOIN "DataObjectTest_Team" '
|
||||
. 'AS "Team" ON "DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID"'
|
||||
. ' ORDER BY "DataObjectTest_TeamComment"."Name" ASC';
|
||||
|
||||
|
||||
$this->assertSQLEquals($expected, $list->sql($parameters));
|
||||
$this->assertEmpty($parameters);
|
||||
}
|
||||
|
||||
public function testLeftJoinParameterised()
|
||||
{
|
||||
$db = DB::get_conn();
|
||||
|
||||
$list = TeamComment::get();
|
||||
$list = $list->leftJoin(
|
||||
'DataObjectTest_Team',
|
||||
'"DataObjectTest_Team"."ID" = "DataObjectTest_TeamComment"."TeamID" '
|
||||
. 'AND "DataObjectTest_Team"."Title" LIKE ?',
|
||||
'Team',
|
||||
20,
|
||||
['Team%']
|
||||
);
|
||||
|
||||
$expected = 'SELECT DISTINCT "DataObjectTest_TeamComment"."ClassName", '
|
||||
. '"DataObjectTest_TeamComment"."LastEdited", "DataObjectTest_TeamComment"."Created", '
|
||||
. '"DataObjectTest_TeamComment"."Name", "DataObjectTest_TeamComment"."Comment", '
|
||||
. '"DataObjectTest_TeamComment"."TeamID", "DataObjectTest_TeamComment"."ID", '
|
||||
. 'CASE WHEN "DataObjectTest_TeamComment"."ClassName" IS NOT NULL'
|
||||
. ' THEN "DataObjectTest_TeamComment"."ClassName" ELSE '
|
||||
. $db->quoteString(DataObjectTest\TeamComment::class)
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" LEFT JOIN '
|
||||
. '"DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = '
|
||||
. ' END AS "RecordClassName" FROM "DataObjectTest_TeamComment" ' . $joinType
|
||||
. ' "DataObjectTest_Team" AS "Team" ON "DataObjectTest_Team"."ID" = '
|
||||
. '"DataObjectTest_TeamComment"."TeamID" '
|
||||
. 'AND "DataObjectTest_Team"."Title" LIKE ?'
|
||||
. ' ORDER BY "DataObjectTest_TeamComment"."Name" ASC';
|
||||
|
|
|
@ -6,16 +6,18 @@ use SilverStripe\ORM\DataQuery;
|
|||
use SilverStripe\ORM\DataObject;
|
||||
use SilverStripe\ORM\DB;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\ORM\ArrayList;
|
||||
use SilverStripe\ORM\Queries\SQLSelect;
|
||||
use SilverStripe\ORM\Tests\DataQueryTest\ObjectE;
|
||||
use SilverStripe\Security\Member;
|
||||
|
||||
class DataQueryTest extends SapphireTest
|
||||
{
|
||||
|
||||
protected static $fixture_file = 'DataQueryTest.yml';
|
||||
|
||||
protected static $extra_dataobjects = [
|
||||
DataQueryTest\DataObjectAddsToQuery::class,
|
||||
DataQueryTest\DateAndPriceObject::class,
|
||||
DataQueryTest\ObjectA::class,
|
||||
DataQueryTest\ObjectB::class,
|
||||
DataQueryTest\ObjectC::class,
|
||||
|
@ -25,6 +27,7 @@ class DataQueryTest extends SapphireTest
|
|||
DataQueryTest\ObjectG::class,
|
||||
DataQueryTest\ObjectH::class,
|
||||
DataQueryTest\ObjectI::class,
|
||||
SQLSelectTest\CteRecursiveObject::class,
|
||||
SQLSelectTest\TestObject::class,
|
||||
SQLSelectTest\TestBase::class,
|
||||
SQLSelectTest\TestChild::class,
|
||||
|
@ -51,22 +54,33 @@ class DataQueryTest extends SapphireTest
|
|||
$this->assertEquals('Foo', $result['Title']);
|
||||
}
|
||||
|
||||
public function provideJoins()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'joinMethod' => 'innerJoin',
|
||||
'joinType' => 'INNER',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'leftJoin',
|
||||
'joinType' => 'LEFT',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'rightJoin',
|
||||
'joinType' => 'RIGHT',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the leftJoin() and innerJoin method of the DataQuery object
|
||||
* @dataProvider provideJoins
|
||||
*/
|
||||
public function testJoins()
|
||||
public function testJoins($joinMethod, $joinType)
|
||||
{
|
||||
$dq = new DataQuery(Member::class);
|
||||
$dq->innerJoin("Group_Members", "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\"");
|
||||
$dq->$joinMethod("Group_Members", "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\"");
|
||||
$this->assertSQLContains(
|
||||
"INNER JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"",
|
||||
$dq->sql($parameters)
|
||||
);
|
||||
|
||||
$dq = new DataQuery(Member::class);
|
||||
$dq->leftJoin("Group_Members", "\"Group_Members\".\"MemberID\" = \"Member\".\"ID\"");
|
||||
$this->assertSQLContains(
|
||||
"LEFT JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"",
|
||||
"$joinType JOIN \"Group_Members\" ON \"Group_Members\".\"MemberID\" = \"Member\".\"ID\"",
|
||||
$dq->sql($parameters)
|
||||
);
|
||||
}
|
||||
|
@ -172,6 +186,33 @@ class DataQueryTest extends SapphireTest
|
|||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function provideFieldCollision()
|
||||
{
|
||||
return [
|
||||
'allow collisions' => [true],
|
||||
'disallow collisions' => [false],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideFieldCollision
|
||||
*/
|
||||
public function testFieldCollision($allowCollisions)
|
||||
{
|
||||
$dataQuery = new DataQuery(DataQueryTest\ObjectB::class);
|
||||
$dataQuery->selectField('COALESCE(NULL, 1) AS "Title"');
|
||||
$dataQuery->setAllowCollidingFieldStatements($allowCollisions);
|
||||
|
||||
if ($allowCollisions) {
|
||||
$this->assertSQLContains('THEN "DataQueryTest_B"."Title" WHEN COALESCE(NULL, 1) AS "Title" IS NOT NULL THEN COALESCE(NULL, 1) AS "Title" ELSE NULL END AS "Title"', $dataQuery->sql());
|
||||
} else {
|
||||
$this->expectError();
|
||||
$this->expectErrorMessageMatches('/^Bad collision item /');
|
||||
}
|
||||
|
||||
$dataQuery->getFinalisedQuery();
|
||||
}
|
||||
|
||||
public function testDisjunctiveGroup()
|
||||
{
|
||||
$dq = new DataQuery(DataQueryTest\ObjectA::class);
|
||||
|
@ -533,4 +574,280 @@ class DataQueryTest extends SapphireTest
|
|||
'exist is false when a limit returns no results'
|
||||
);
|
||||
}
|
||||
|
||||
public function provideWith()
|
||||
{
|
||||
return [
|
||||
// Simple scenarios to test auto-join functionality
|
||||
'naive CTE query with array join' => [
|
||||
'dataClass' => DataQueryTest\DateAndPriceObject::class,
|
||||
'name' => 'cte',
|
||||
'query' => new SQLSelect(
|
||||
['"DataQueryTest_DateAndPriceObject"."ID"'],
|
||||
'"DataQueryTest_DateAndPriceObject"',
|
||||
['"DataQueryTest_DateAndPriceObject"."Price" > 200']
|
||||
),
|
||||
'cteFields' => ['cte_id'],
|
||||
'recursive' => false,
|
||||
'extraManipulations' => [
|
||||
'innerJoin' => ['cte', '"DataQueryTest_DateAndPriceObject"."ID" = "cte"."cte_id"'],
|
||||
],
|
||||
'expectedItems' => [
|
||||
'fixtures' => [
|
||||
'obj4',
|
||||
'obj5',
|
||||
],
|
||||
],
|
||||
],
|
||||
'naive CTE query with string join' => [
|
||||
'dataClass' => DataQueryTest\DateAndPriceObject::class,
|
||||
'name' => 'cte',
|
||||
'query' => new SQLSelect('200'),
|
||||
'cteFields' => ['value'],
|
||||
'recursive' => false,
|
||||
'extraManipulations' => [
|
||||
'innerJoin' => ['cte', '"DataQueryTest_DateAndPriceObject"."Price" < "cte"."value"'],
|
||||
],
|
||||
'expectedItems' => [
|
||||
'fixtures' => [
|
||||
'nullobj',
|
||||
'obj1',
|
||||
'obj2',
|
||||
]
|
||||
],
|
||||
],
|
||||
// Simple scenario to test where the query is another DataQuery
|
||||
'naive CTE query with DataQuery' => [
|
||||
'dataClass' => DataQueryTest\DateAndPriceObject::class,
|
||||
'name' => 'cte',
|
||||
'query' => DataQueryTest\ObjectF::class,
|
||||
'cteFields' => ['MyDate'],
|
||||
'recursive' => false,
|
||||
'extraManipulations' => [
|
||||
'innerJoin' => ['cte', '"DataQueryTest_DateAndPriceObject"."Date" = "cte"."MyDate"'],
|
||||
],
|
||||
'expectedItems' => [
|
||||
'fixtures' => [
|
||||
'obj1',
|
||||
'obj2',
|
||||
]
|
||||
],
|
||||
],
|
||||
// Extrapolate missing data with a recursive query
|
||||
// Missing data will be returned as records with no ID
|
||||
'recursive CTE with extrapolated data' => [
|
||||
'dataClass' => DataQueryTest\DateAndPriceObject::class,
|
||||
'name' => 'dates',
|
||||
'query' => (new SQLSelect(
|
||||
'MIN("DataQueryTest_DateAndPriceObject"."Date")',
|
||||
"DataQueryTest_DateAndPriceObject",
|
||||
'"DataQueryTest_DateAndPriceObject"."Date" IS NOT NULL'
|
||||
))->addUnion(
|
||||
new SQLSelect(
|
||||
'Date + INTERVAL 1 DAY',
|
||||
'dates',
|
||||
['Date + INTERVAL 1 DAY <= (SELECT MAX("DataQueryTest_DateAndPriceObject"."Date") FROM "DataQueryTest_DateAndPriceObject")']
|
||||
),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => ['Date'],
|
||||
'recursive' => true,
|
||||
'extraManipulations' => [
|
||||
'selectField' => ['COALESCE("DataQueryTest_DateAndPriceObject"."Date", "dates"."Date")', 'Date'],
|
||||
'setAllowCollidingFieldStatements' => [true],
|
||||
'sort' => ['dates.Date'],
|
||||
'rightJoin' => ['dates', '"DataQueryTest_DateAndPriceObject"."Date" = "dates"."Date"'],
|
||||
],
|
||||
'expectedItems' => [
|
||||
'data' => [
|
||||
['fixtureName' => 'obj5'],
|
||||
['fixtureName' => 'obj4'],
|
||||
['Date' => '2023-01-06'],
|
||||
['Date' => '2023-01-05'],
|
||||
['fixtureName' => 'obj3'],
|
||||
['Date' => '2023-01-03'],
|
||||
['fixtureName' => 'obj2'],
|
||||
['fixtureName' => 'obj1'],
|
||||
]
|
||||
],
|
||||
],
|
||||
// Get the ancestors of a given record with a recursive query
|
||||
'complex hierarchical CTE with explicit columns' => [
|
||||
'dataClass' => SQLSelectTest\CteRecursiveObject::class,
|
||||
'name' => 'hierarchy',
|
||||
'query' => (
|
||||
new SQLSelect(
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
"SQLSelectTestCteRecursive",
|
||||
[['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."Title" = ?' => 'child of child1']]
|
||||
)
|
||||
)->addUnion(new SQLSelect(
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
['"hierarchy"', '"SQLSelectTestCteRecursive"'],
|
||||
['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"']
|
||||
)),
|
||||
'cteFields' => ['parent_id'],
|
||||
'recursive' => true,
|
||||
'extraManipulations' => [
|
||||
'innerJoin' => ['hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"'],
|
||||
],
|
||||
'expected' => [
|
||||
'fixtures' => [
|
||||
'grandparent',
|
||||
'parent',
|
||||
'child1',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideWith
|
||||
*/
|
||||
public function testWith(
|
||||
string $dataClass,
|
||||
string $name,
|
||||
string|SQLSelect $query,
|
||||
array $cteFields,
|
||||
bool $recursive,
|
||||
array $extraManipulations,
|
||||
array $expectedItems
|
||||
) {
|
||||
if (!DB::get_conn()->supportsCteQueries()) {
|
||||
$this->markTestSkipped('The current database does not support WITH clauses');
|
||||
}
|
||||
if ($recursive && !DB::get_conn()->supportsCteQueries(true)) {
|
||||
$this->markTestSkipped('The current database does not support recursive WITH clauses');
|
||||
}
|
||||
|
||||
// We can't instantiate a DataQuery in a provider method because it requires the injector, which isn't
|
||||
// initialised that early. So we just pass the dataclass instead and instiate the query here.
|
||||
if (is_string($query)) {
|
||||
$query = new DataQuery($query);
|
||||
}
|
||||
|
||||
$dataQuery = new DataQuery($dataClass);
|
||||
$dataQuery->with($name, $query, $cteFields, $recursive);
|
||||
|
||||
foreach ($extraManipulations as $method => $args) {
|
||||
$dataQuery->$method(...$args);
|
||||
}
|
||||
|
||||
$expected = [];
|
||||
|
||||
if (isset($expectedItems['fixtures'])) {
|
||||
foreach ($expectedItems['fixtures'] as $fixtureName) {
|
||||
$expected[] = $this->idFromFixture($dataClass, $fixtureName);
|
||||
}
|
||||
$this->assertEquals($expected, $dataQuery->execute()->column('ID'));
|
||||
}
|
||||
|
||||
if (isset($expectedItems['data'])) {
|
||||
foreach ($expectedItems['data'] as $data) {
|
||||
if (isset($data['fixtureName'])) {
|
||||
$data = $this->objFromFixture($dataClass, $data['fixtureName'])->toMap();
|
||||
} else {
|
||||
$data['ClassName'] = null;
|
||||
$data['LastEdited'] = null;
|
||||
$data['Created'] = null;
|
||||
$data['Price'] = null;
|
||||
$data['ID'] = null;
|
||||
}
|
||||
$expected[] = $data;
|
||||
}
|
||||
$this->assertListEquals($expected, new ArrayList(iterator_to_array($dataQuery->execute(), true)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tests the WITH clause, using a DataQuery as the CTE query
|
||||
*/
|
||||
public function testWithUsingDataQuery()
|
||||
{
|
||||
if (!DB::get_conn()->supportsCteQueries(true)) {
|
||||
$this->markTestSkipped('The current database does not support recursive WITH clauses');
|
||||
}
|
||||
$dataQuery = new DataQuery(SQLSelectTest\CteRecursiveObject::class);
|
||||
$cteQuery = new DataQuery(SQLSelectTest\CteRecursiveObject::class);
|
||||
$cteQuery->where([
|
||||
'"SQLSelectTestCteRecursive"."ParentID" > 0',
|
||||
'"SQLSelectTestCteRecursive"."Title" = ?' => 'child of child2'
|
||||
]);
|
||||
$cteQuery->union(new SQLSelect(
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
['"hierarchy"', '"SQLSelectTestCteRecursive"'],
|
||||
[
|
||||
'"SQLSelectTestCteRecursive"."ParentID" > 0',
|
||||
'"SQLSelectTestCteRecursive"."ID" = "hierarchy"."ParentID"'
|
||||
]
|
||||
));
|
||||
$dataQuery->with('hierarchy', $cteQuery, ['ParentID'], true);
|
||||
$dataQuery->innerJoin('hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."ParentID"');
|
||||
|
||||
$expectedFixtures = [
|
||||
'child2',
|
||||
'parent',
|
||||
'grandparent',
|
||||
];
|
||||
$expectedData = [];
|
||||
foreach ($expectedFixtures as $fixtureName) {
|
||||
$expectedData[] = $this->objFromFixture(SQLSelectTest\CteRecursiveObject::class, $fixtureName)->toMap();
|
||||
}
|
||||
$this->assertListEquals($expectedData, new ArrayList(iterator_to_array($dataQuery->execute(), true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* tests the WITH clause, using a DataQuery as the CTE query and as the unioned recursive query
|
||||
*/
|
||||
public function testWithUsingOnlyDataQueries()
|
||||
{
|
||||
if (!DB::get_conn()->supportsCteQueries(true)) {
|
||||
$this->markTestSkipped('The current database does not support recursive WITH clauses');
|
||||
}
|
||||
$dataQuery = new DataQuery(SQLSelectTest\CteRecursiveObject::class);
|
||||
$cteQuery = new DataQuery(SQLSelectTest\CteRecursiveObject::class);
|
||||
$cteQuery->where([
|
||||
'"SQLSelectTestCteRecursive"."ParentID" > 0',
|
||||
'"SQLSelectTestCteRecursive"."Title" = ?' => 'child of child2'
|
||||
]);
|
||||
$cteQuery->union((new DataQuery(SQLSelectTest\CteRecursiveObject::class))
|
||||
->innerJoin('hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."ParentID"')
|
||||
->where('"SQLSelectTestCteRecursive"."ParentID" > 0')
|
||||
->sort(null)
|
||||
->distinct(false));
|
||||
// This test exists because previously when $cteFields was empty, it would cause an error with the above setup.
|
||||
$dataQuery->with('hierarchy', $cteQuery, [], true);
|
||||
$dataQuery->innerJoin('hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."ParentID"');
|
||||
|
||||
$expectedFixtures = [
|
||||
'child2',
|
||||
'parent',
|
||||
'grandparent',
|
||||
];
|
||||
$expectedData = [];
|
||||
foreach ($expectedFixtures as $fixtureName) {
|
||||
$expectedData[] = $this->objFromFixture(SQLSelectTest\CteRecursiveObject::class, $fixtureName)->toMap();
|
||||
}
|
||||
$this->assertListEquals($expectedData, new ArrayList(iterator_to_array($dataQuery->execute(), true)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that CTE queries have appropriate JOINs for subclass tables etc.
|
||||
* If `$query->query()->` was replaced with `$query->query->` in DataQuery::with(), this test would throw an exception.
|
||||
* @doesNotPerformAssertions
|
||||
*/
|
||||
public function testWithUsingDataQueryAppliesRelations()
|
||||
{
|
||||
if (!DB::get_conn()->supportsCteQueries()) {
|
||||
$this->markTestSkipped('The current database does not support WITH clauses');
|
||||
}
|
||||
$dataQuery = new DataQuery(DataQueryTest\ObjectG::class);
|
||||
$cteQuery = new DataQuery(DataQueryTest\ObjectG::class);
|
||||
$cteQuery->where(['"DataQueryTest_G"."SubClassOnlyField" = ?' => 'This is the one']);
|
||||
$dataQuery->with('test_implicit_joins', $cteQuery, ['ID']);
|
||||
$dataQuery->innerJoin('test_implicit_joins', '"DataQueryTest_G"."ID" = "test_implicit_joins"."ID"');
|
||||
// This will throw an exception if it fails - it passes if there's no exception.
|
||||
$dataQuery->execute();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,16 @@ SilverStripe\ORM\Tests\DataQueryTest\ObjectE:
|
|||
Title: 'Second'
|
||||
SortOrder: 2
|
||||
|
||||
SilverStripe\ORM\Tests\DataQueryTest\ObjectF:
|
||||
query1:
|
||||
MyDate: '2023-06-01'
|
||||
query2:
|
||||
MyDate: '2023-01-01'
|
||||
query3:
|
||||
MyDate: '2023-01-02'
|
||||
query4:
|
||||
MyDate: '2023-06-02'
|
||||
|
||||
SilverStripe\ORM\Tests\DataQueryTest\ObjectI:
|
||||
query1:
|
||||
Title: 'First'
|
||||
|
@ -41,3 +51,42 @@ SilverStripe\ORM\Tests\DataQueryTest\DataObjectAddsToQuery:
|
|||
obj1:
|
||||
FieldOne: 'This is a value'
|
||||
FieldTwo: 'This is also a value'
|
||||
|
||||
SilverStripe\ORM\Tests\DataQueryTest\DateAndPriceObject:
|
||||
nullobj:
|
||||
Date: null
|
||||
Price: null
|
||||
obj1:
|
||||
Price: 0
|
||||
Date: '2023-01-01'
|
||||
obj2:
|
||||
Price: 100
|
||||
Date: '2023-01-02'
|
||||
obj3:
|
||||
Price: 200
|
||||
Date: '2023-01-04'
|
||||
obj4:
|
||||
Price: 300
|
||||
Date: '2023-01-07'
|
||||
obj5:
|
||||
Price: 400
|
||||
Date: '2023-01-08'
|
||||
|
||||
SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject:
|
||||
grandparent:
|
||||
Title: 'grandparent'
|
||||
parent:
|
||||
Title: 'parent'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.grandparent
|
||||
child1:
|
||||
Title: 'child1'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.parent
|
||||
child2:
|
||||
Title: 'child2'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.parent
|
||||
child-of-child1:
|
||||
Title: 'child of child1'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.child1
|
||||
child-of-child2:
|
||||
Title: 'child of child2'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.child2
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\DataQueryTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class DateAndPriceObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'DataQueryTest_DateAndPriceObject';
|
||||
|
||||
private static $db = [
|
||||
'Date' => 'Date',
|
||||
'Price' => 'Int',
|
||||
];
|
||||
}
|
|
@ -8,6 +8,10 @@ class ObjectG extends ObjectC implements TestOnly
|
|||
{
|
||||
private static $table_name = 'DataQueryTest_G';
|
||||
|
||||
private static $db = [
|
||||
'SubClassOnlyField' => 'Text',
|
||||
];
|
||||
|
||||
private static $belongs_many_many = [
|
||||
'ManyTestEs' => ObjectE::class,
|
||||
];
|
||||
|
|
|
@ -953,6 +953,13 @@ class EagerLoadedListTest extends SapphireTest
|
|||
$this->assertFalse($subteam->canSortBy('SomethingElse'));
|
||||
}
|
||||
|
||||
public function testCannotSortByRelation()
|
||||
{
|
||||
$list = $this->getListWithRecords(TeamComment::class);
|
||||
$this->assertFalse($list->canSortBy('Team'));
|
||||
$this->assertFalse($list->canSortBy('Team.Title'));
|
||||
}
|
||||
|
||||
public function testArrayAccess()
|
||||
{
|
||||
$list = $this->getListWithRecords(Team::class)->sort('Title');
|
||||
|
@ -1112,6 +1119,14 @@ class EagerLoadedListTest extends SapphireTest
|
|||
);
|
||||
}
|
||||
|
||||
public function testSortByRelation()
|
||||
{
|
||||
$list = $this->getListWithRecords(TeamComment::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Cannot sort by relations on EagerLoadedList');
|
||||
$list = $list->sort('Team.Title', 'ASC');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSortInvalidParameters
|
||||
*/
|
||||
|
@ -1324,6 +1339,26 @@ class EagerLoadedListTest extends SapphireTest
|
|||
$this->assertTrue($subteam->canFilterBy("SubclassDatabaseField"));
|
||||
}
|
||||
|
||||
public function testCannotFilterByRelation()
|
||||
{
|
||||
$list = $this->getListWithRecords(Team::class);
|
||||
|
||||
$this->assertFalse($list->canFilterBy('Captain.ShirtNumber'));
|
||||
$this->assertFalse($list->canFilterBy('SomethingElse.ShirtNumber'));
|
||||
$this->assertFalse($list->canFilterBy('Captain.SomethingElse'));
|
||||
$this->assertFalse($list->canFilterBy('Captain.FavouriteTeam.Captain.ShirtNumber'));
|
||||
|
||||
// Has many
|
||||
$this->assertFalse($list->canFilterBy('Fans.Name'));
|
||||
$this->assertFalse($list->canFilterBy('SomethingElse.Name'));
|
||||
$this->assertFalse($list->canFilterBy('Fans.SomethingElse'));
|
||||
|
||||
// Many many
|
||||
$this->assertFalse($list->canFilterBy('Players.FirstName'));
|
||||
$this->assertFalse($list->canFilterBy('SomethingElse.FirstName'));
|
||||
$this->assertFalse($list->canFilterBy('Players.SomethingElse'));
|
||||
}
|
||||
|
||||
public function testAddfilter()
|
||||
{
|
||||
$list = $this->getListWithRecords(TeamComment::class);
|
||||
|
@ -1441,6 +1476,60 @@ class EagerLoadedListTest extends SapphireTest
|
|||
$this->assertEquals(2, count($list->exclude('ID', $id) ?? []));
|
||||
}
|
||||
|
||||
public function testFilterAnyByRelation()
|
||||
{
|
||||
$list = $this->getListWithRecords(Player::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Can't filter by column 'Teams.Title'");
|
||||
$list = $list->filterAny(['Teams.Title' => 'Team']);
|
||||
}
|
||||
|
||||
public function testFilterAggregate()
|
||||
{
|
||||
$list = $this->getListWithRecords(Team::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Can't filter by column 'Players.Count()'");
|
||||
$list->filter(['Players.Count()' => 2]);
|
||||
}
|
||||
|
||||
public function testFilterAnyAggregate()
|
||||
{
|
||||
$list = $this->getListWithRecords(Team::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Can't filter by column 'Players.Count()'");
|
||||
$list->filterAny(['Players.Count()' => 2]);
|
||||
}
|
||||
|
||||
public function provideCantFilterByRelation()
|
||||
{
|
||||
return [
|
||||
'many_many' => [
|
||||
'Players.FirstName',
|
||||
],
|
||||
'has_many' => [
|
||||
'Comments.Name',
|
||||
],
|
||||
'has_one' => [
|
||||
'FavouriteTeam.Title',
|
||||
],
|
||||
'non-existent relation' => [
|
||||
'MascotAnimal.Name',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideCantFilterByRelation
|
||||
*/
|
||||
public function testCantFilterByRelation(string $column)
|
||||
{
|
||||
// Many to many
|
||||
$list = $this->getListWithRecords(Team::class);
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage("Can't filter by column '$column'");
|
||||
$list->filter($column, ['Captain', 'Captain 2']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideFilterByNull
|
||||
*/
|
||||
|
|
|
@ -8,6 +8,8 @@ use SilverStripe\ORM\DB;
|
|||
use SilverStripe\ORM\Connect\MySQLiConnector;
|
||||
use SilverStripe\ORM\Queries\SQLUpdate;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\ORM\Connect\MySQLDatabase;
|
||||
use SilverStripe\ORM\Tests\MySQLSchemaManagerTest\MySQLDBDummy;
|
||||
|
||||
class MySQLDatabaseTest extends SapphireTest
|
||||
{
|
||||
|
@ -110,4 +112,105 @@ class MySQLDatabaseTest extends SapphireTest
|
|||
$this->assertInstanceOf(MySQLQuery::class, $result);
|
||||
$this->assertEquals(1, DB::affected_rows());
|
||||
}
|
||||
|
||||
public function provideSupportsCte()
|
||||
{
|
||||
return [
|
||||
// mysql unsupported
|
||||
[
|
||||
'version' => '1.1.1',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '5.9999.9999',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '8.0.0',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
// mysql supported
|
||||
[
|
||||
'version' => '8.0.1',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.0',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
[
|
||||
'version' => '999.999.999',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
// mariaDB unsupported (various formats)
|
||||
[
|
||||
'version' => '5.5.5-10.2.0-mariadb-1:10.6.8+maria~focal',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.0-mariadb-1:10.6.8+maria~jammy',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.0-mariadb-1:10.2.0+maria~focal',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
// mariadb supported (various formats)
|
||||
[
|
||||
'version' => '5.5.5-10.2.1-mariadb-1:10.6.8+maria~focal',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.1-mariadb-1:10.6.8+maria~jammy',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.1-mariadb-1:10.2.1+maria~focal',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
[
|
||||
'version' => '10.2.2-mariadb-1:10.2.2+maria~jammy',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
[
|
||||
'version' => '5.5.5-10.2.2-mariadb-1:10.2.2+maria~jammy',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
[
|
||||
'version' => '5.5.5-999.999.999-mariadb-1:10.2.2+maria~jammy',
|
||||
'expected' => true,
|
||||
'expectedRecursive' => true,
|
||||
],
|
||||
// completely invalid versions
|
||||
[
|
||||
'version' => '999.999.999-some-random-string',
|
||||
'expected' => false,
|
||||
'expectedRecursive' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSupportsCte
|
||||
*/
|
||||
public function testSupportsCte(string $version, bool $expected, bool $expectedRecursive)
|
||||
{
|
||||
$database = new MySQLDBDummy($version);
|
||||
$this->assertSame($expected, $database->supportsCteQueries());
|
||||
$this->assertSame($expectedRecursive, $database->supportsCteQueries(true));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,12 +3,17 @@
|
|||
namespace SilverStripe\ORM\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LogicException;
|
||||
use SilverStripe\ORM\DB;
|
||||
use SilverStripe\ORM\Connect\MySQLDatabase;
|
||||
use SilverStripe\ORM\Queries\SQLSelect;
|
||||
use SilverStripe\SQLite\SQLite3Database;
|
||||
use SilverStripe\PostgreSQL\PostgreSQLDatabase;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\ORM\ArrayList;
|
||||
use SilverStripe\ORM\Connect\DatabaseException;
|
||||
use SilverStripe\ORM\Tests\SQLSelectTest\CteDatesObject;
|
||||
use SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject;
|
||||
|
||||
class SQLSelectTest extends SapphireTest
|
||||
{
|
||||
|
@ -18,7 +23,9 @@ class SQLSelectTest extends SapphireTest
|
|||
protected static $extra_dataobjects = [
|
||||
SQLSelectTest\TestObject::class,
|
||||
SQLSelectTest\TestBase::class,
|
||||
SQLSelectTest\TestChild::class
|
||||
SQLSelectTest\TestChild::class,
|
||||
SQLSelectTest\CteDatesObject::class,
|
||||
SQLSelectTest\CteRecursiveObject::class,
|
||||
];
|
||||
|
||||
protected $oldDeprecation = null;
|
||||
|
@ -67,19 +74,80 @@ class SQLSelectTest extends SapphireTest
|
|||
}
|
||||
}
|
||||
|
||||
public function provideIsEmpty()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'query' => new SQLSelect(),
|
||||
'expected' => true,
|
||||
],
|
||||
[
|
||||
'query' => new SQLSelect(from: 'someTable'),
|
||||
'expected' => false,
|
||||
],
|
||||
[
|
||||
'query' => new SQLSelect(''),
|
||||
'expected' => true,
|
||||
],
|
||||
[
|
||||
'query' => new SQLSelect('', 'someTable'),
|
||||
'expected' => true,
|
||||
],
|
||||
[
|
||||
'query' => new SQLSelect('column', 'someTable'),
|
||||
'expected' => false,
|
||||
],
|
||||
[
|
||||
'query' => new SQLSelect('value'),
|
||||
'expected' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideIsEmpty
|
||||
*/
|
||||
public function testIsEmpty(SQLSelect $query, $expected)
|
||||
{
|
||||
$this->assertSame($expected, $query->isEmpty());
|
||||
}
|
||||
|
||||
public function testEmptyQueryReturnsNothing()
|
||||
{
|
||||
$query = new SQLSelect();
|
||||
$this->assertSQLEquals('', $query->sql($parameters));
|
||||
}
|
||||
|
||||
public function testSelectFromBasicTable()
|
||||
public function provideSelectFrom()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'from' => ['MyTable'],
|
||||
'expected' => 'SELECT * FROM MyTable',
|
||||
],
|
||||
[
|
||||
'from' => ['MyTable', 'MySecondTable'],
|
||||
'expected' => 'SELECT * FROM MyTable, MySecondTable',
|
||||
],
|
||||
[
|
||||
'from' => ['MyTable', 'INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID'],
|
||||
'expected' => 'SELECT * FROM MyTable INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID',
|
||||
],
|
||||
[
|
||||
'from' => ['MyTable', 'MySecondTable', 'INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID'],
|
||||
'expected' => 'SELECT * FROM MyTable, MySecondTable INNER JOIN AnotherTable on AnotherTable.ID = MyTable.SomeFieldID',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideSelectFrom
|
||||
*/
|
||||
public function testSelectFrom(array $from, string $expected)
|
||||
{
|
||||
$query = new SQLSelect();
|
||||
$query->setFrom('MyTable');
|
||||
$this->assertSQLEquals("SELECT * FROM MyTable", $query->sql($parameters));
|
||||
$query->addFrom('MyJoin');
|
||||
$this->assertSQLEquals("SELECT * FROM MyTable MyJoin", $query->sql($parameters));
|
||||
$query->setFrom($from);
|
||||
$this->assertSQLEquals($expected, $query->sql($parameters));
|
||||
}
|
||||
|
||||
public function testSelectFromUserSpecifiedFields()
|
||||
|
@ -435,16 +503,18 @@ class SQLSelectTest extends SapphireTest
|
|||
);
|
||||
}
|
||||
|
||||
public function testInnerJoin()
|
||||
public function testJoinSQL()
|
||||
{
|
||||
$query = new SQLSelect();
|
||||
$query->setFrom('MyTable');
|
||||
$query->addInnerJoin('MyOtherTable', 'MyOtherTable.ID = 2');
|
||||
$query->addRightJoin('MySecondTable', 'MyOtherTable.ID = MySecondTable.ID');
|
||||
$query->addLeftJoin('MyLastTable', 'MyOtherTable.ID = MyLastTable.ID');
|
||||
|
||||
$this->assertSQLEquals(
|
||||
'SELECT * FROM MyTable ' .
|
||||
'INNER JOIN "MyOtherTable" ON MyOtherTable.ID = 2 ' .
|
||||
'RIGHT JOIN "MySecondTable" ON MyOtherTable.ID = MySecondTable.ID ' .
|
||||
'LEFT JOIN "MyLastTable" ON MyOtherTable.ID = MyLastTable.ID',
|
||||
$query->sql($parameters)
|
||||
);
|
||||
|
@ -452,12 +522,14 @@ class SQLSelectTest extends SapphireTest
|
|||
$query = new SQLSelect();
|
||||
$query->setFrom('MyTable');
|
||||
$query->addInnerJoin('MyOtherTable', 'MyOtherTable.ID = 2', 'table1');
|
||||
$query->addLeftJoin('MyLastTable', 'MyOtherTable.ID = MyLastTable.ID', 'table2');
|
||||
$query->addRightJoin('MySecondTable', 'MyOtherTable.ID = MySecondTable.ID', 'table2');
|
||||
$query->addLeftJoin('MyLastTable', 'MyOtherTable.ID = MyLastTable.ID', 'table3');
|
||||
|
||||
$this->assertSQLEquals(
|
||||
'SELECT * FROM MyTable ' .
|
||||
'INNER JOIN "MyOtherTable" AS "table1" ON MyOtherTable.ID = 2 ' .
|
||||
'LEFT JOIN "MyLastTable" AS "table2" ON MyOtherTable.ID = MyLastTable.ID',
|
||||
'RIGHT JOIN "MySecondTable" AS "table2" ON MyOtherTable.ID = MySecondTable.ID ' .
|
||||
'LEFT JOIN "MyLastTable" AS "table3" ON MyOtherTable.ID = MyLastTable.ID',
|
||||
$query->sql($parameters)
|
||||
);
|
||||
}
|
||||
|
@ -724,6 +796,13 @@ class SQLSelectTest extends SapphireTest
|
|||
);
|
||||
}
|
||||
|
||||
public function testSelectWithNoTable()
|
||||
{
|
||||
$query = new SQLSelect('200');
|
||||
$this->assertSQLEquals('SELECT 200 AS "200"', $query->sql());
|
||||
$this->assertSame([['200' => 200]], iterator_to_array($query->execute(), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test passing in a LIMIT with OFFSET clause string.
|
||||
*/
|
||||
|
@ -739,12 +818,33 @@ class SQLSelectTest extends SapphireTest
|
|||
$this->assertEquals(10, $limit['start']);
|
||||
}
|
||||
|
||||
public function testParameterisedInnerJoins()
|
||||
public function provideParameterisedJoinSQL()
|
||||
{
|
||||
return [
|
||||
[
|
||||
'joinMethod' => 'addInnerJoin',
|
||||
'joinType' => 'INNER',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'addLeftJoin',
|
||||
'joinType' => 'LEFT',
|
||||
],
|
||||
[
|
||||
'joinMethod' => 'addRightJoin',
|
||||
'joinType' => 'RIGHT',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideParameterisedJoinSQL
|
||||
*/
|
||||
public function testParameterisedJoinSQL($joinMethod, $joinType)
|
||||
{
|
||||
$query = new SQLSelect();
|
||||
$query->setSelect(['"SQLSelectTest_DO"."Name"', '"SubSelect"."Count"']);
|
||||
$query->setFrom('"SQLSelectTest_DO"');
|
||||
$query->addInnerJoin(
|
||||
$query->$joinMethod(
|
||||
'(SELECT "Title", COUNT(*) AS "Count" FROM "SQLSelectTestBase" GROUP BY "Title" HAVING "Title" NOT LIKE ?)',
|
||||
'"SQLSelectTest_DO"."Name" = "SubSelect"."Title"',
|
||||
'SubSelect',
|
||||
|
@ -755,7 +855,7 @@ class SQLSelectTest extends SapphireTest
|
|||
|
||||
$this->assertSQLEquals(
|
||||
'SELECT "SQLSelectTest_DO"."Name", "SubSelect"."Count"
|
||||
FROM "SQLSelectTest_DO" INNER JOIN (SELECT "Title", COUNT(*) AS "Count" FROM "SQLSelectTestBase"
|
||||
FROM "SQLSelectTest_DO" ' . $joinType . ' JOIN (SELECT "Title", COUNT(*) AS "Count" FROM "SQLSelectTestBase"
|
||||
GROUP BY "Title" HAVING "Title" NOT LIKE ?) AS "SubSelect" ON "SQLSelectTest_DO"."Name" =
|
||||
"SubSelect"."Title"
|
||||
WHERE ("SQLSelectTest_DO"."Date" > ?)',
|
||||
|
@ -765,30 +865,54 @@ class SQLSelectTest extends SapphireTest
|
|||
$query->execute();
|
||||
}
|
||||
|
||||
public function testParameterisedLeftJoins()
|
||||
public function provideUnion()
|
||||
{
|
||||
$query = new SQLSelect();
|
||||
$query->setSelect(['"SQLSelectTest_DO"."Name"', '"SubSelect"."Count"']);
|
||||
$query->setFrom('"SQLSelectTest_DO"');
|
||||
$query->addLeftJoin(
|
||||
'(SELECT "Title", COUNT(*) AS "Count" FROM "SQLSelectTestBase" GROUP BY "Title" HAVING "Title" NOT LIKE ?)',
|
||||
'"SQLSelectTest_DO"."Name" = "SubSelect"."Title"',
|
||||
'SubSelect',
|
||||
20,
|
||||
['%MyName%']
|
||||
);
|
||||
$query->addWhere(['"SQLSelectTest_DO"."Date" > ?' => '2012-08-08 12:00']);
|
||||
return [
|
||||
// Note that a default (null) UNION is identical to a DISTINCT UNION
|
||||
[
|
||||
'unionQuery' => new SQLSelect([1, 2]),
|
||||
'type' => null,
|
||||
'expected' => [
|
||||
[1 => 1, 2 => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'unionQuery' => new SQLSelect([1, 2]),
|
||||
'type' => SQLSelect::UNION_DISTINCT,
|
||||
'expected' => [
|
||||
[1 => 1, 2 => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'unionQuery' => new SQLSelect([1, 2]),
|
||||
'type' => SQLSelect::UNION_ALL,
|
||||
'expected' => [
|
||||
[1 => 1, 2 => 2],
|
||||
[1 => 1, 2 => 2],
|
||||
],
|
||||
],
|
||||
[
|
||||
'unionQuery' => new SQLSelect([1, 2]),
|
||||
'type' => 'tulips',
|
||||
'expected' => LogicException::class,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$this->assertSQLEquals(
|
||||
'SELECT "SQLSelectTest_DO"."Name", "SubSelect"."Count"
|
||||
FROM "SQLSelectTest_DO" LEFT JOIN (SELECT "Title", COUNT(*) AS "Count" FROM "SQLSelectTestBase"
|
||||
GROUP BY "Title" HAVING "Title" NOT LIKE ?) AS "SubSelect" ON "SQLSelectTest_DO"."Name" =
|
||||
"SubSelect"."Title"
|
||||
WHERE ("SQLSelectTest_DO"."Date" > ?)',
|
||||
$query->sql($parameters)
|
||||
);
|
||||
$this->assertEquals(['%MyName%', '2012-08-08 12:00'], $parameters);
|
||||
$query->execute();
|
||||
/**
|
||||
* @dataProvider provideUnion
|
||||
*/
|
||||
public function testUnion(SQLSelect $unionQuery, ?string $type, string|array $expected)
|
||||
{
|
||||
if (is_string($expected)) {
|
||||
$this->expectException($expected);
|
||||
$this->expectExceptionMessage('Union $type must be one of the constants UNION_ALL or UNION_DISTINCT.');
|
||||
}
|
||||
|
||||
$query = new SQLSelect([1, 2]);
|
||||
$query->addUnion($unionQuery, $type);
|
||||
|
||||
$this->assertSame($expected, iterator_to_array($query->execute(), true));
|
||||
}
|
||||
|
||||
public function testBaseTableAliases()
|
||||
|
@ -819,14 +943,388 @@ class SQLSelectTest extends SapphireTest
|
|||
// In SS4 the "explicitAlias" would be ignored
|
||||
$query = SQLSelect::create('*', [
|
||||
'MyTableAlias' => '"MyTable"',
|
||||
'explicitAlias' => ', (SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin"'
|
||||
'explicitAlias' => '(SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin"'
|
||||
]);
|
||||
$sql = $query->sql();
|
||||
|
||||
$this->assertSQLEquals(
|
||||
'SELECT * FROM "MyTable" AS "MyTableAlias" , ' .
|
||||
'SELECT * FROM "MyTable" AS "MyTableAlias", ' .
|
||||
'(SELECT * FROM "MyTable" where "something" = "whatever") as "CrossJoin" AS "explicitAlias"',
|
||||
$sql
|
||||
);
|
||||
}
|
||||
|
||||
public function provideWith()
|
||||
{
|
||||
// Each of these examples shows it working with aliased implicit columns, and with explicit CTE columns.
|
||||
// Most of these examples are derived from https://dev.mysql.com/doc/refman/8.0/en/with.html
|
||||
return [
|
||||
// Just a CTE, no union
|
||||
'basic CTE with aliased columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => new SQLSelect(['col1' => 1, 'col2' => 2]),
|
||||
'cteFields' => [],
|
||||
'recursive' => false,
|
||||
'selectFields' => ['col1', 'col2'],
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [['col1' => 1, 'col2' => 2]],
|
||||
],
|
||||
'basic CTE with explicit columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => new SQLSelect([1, 2]),
|
||||
'cteFields' => ['col1', 'col2'],
|
||||
'recursive' => false,
|
||||
'selectFields' => ['col1', 'col2'],
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [['col1' => 1, 'col2' => 2]],
|
||||
],
|
||||
// CTE with a simple union, non-recursive
|
||||
'basic unioned CTE with aliased columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => (new SQLSelect(['col1' => 1, 'col2' => 2]))->addUnion(
|
||||
new SQLSelect(['ignoredAlias1' => '3', 'ignoredAlias2' => '4']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => false,
|
||||
'selectFields' => ['col1', 'col2'],
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['col1' => 1, 'col2' => 2],
|
||||
['col1' => 3, 'col2' => 4],
|
||||
],
|
||||
],
|
||||
'basic unioned CTE with explicit columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => (new SQLSelect([1, 2]))->addUnion(new SQLSelect(['3', '4']), SQLSelect::UNION_ALL),
|
||||
'cteFields' => ['col1', 'col2'],
|
||||
'recursive' => false,
|
||||
'selectFields' => ['col1', 'col2'],
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['col1' => 1, 'col2' => 2],
|
||||
['col1' => 3, 'col2' => 4],
|
||||
],
|
||||
],
|
||||
// Recursive CTE with only one field in it
|
||||
'basic recursive CTE with aliased columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => (new SQLSelect(['str' => "CAST('abc' AS CHAR(20))"]))->addUnion(
|
||||
new SQLSelect(['ignoredAlias' => 'CONCAT(str, str)'], 'cte', ['LENGTH(str) < 10']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => true,
|
||||
'selectFields' => '*',
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['str' => 'abc'],
|
||||
['str' => 'abcabc'],
|
||||
['str' => 'abcabcabcabc'],
|
||||
],
|
||||
],
|
||||
'basic recursive CTE with explicit columns' => [
|
||||
'name' => 'cte',
|
||||
'query' => (new SQLSelect("CAST('abc' AS CHAR(20))"))->addUnion(
|
||||
new SQLSelect('CONCAT(str, str)', 'cte', ['LENGTH(str) < 10']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => ['str'],
|
||||
'recursive' => true,
|
||||
'selectFields' => '*',
|
||||
'selectFrom' => 'cte',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['str' => 'abc'],
|
||||
['str' => 'abcabc'],
|
||||
['str' => 'abcabcabcabc'],
|
||||
],
|
||||
],
|
||||
// More complex recursive CTE
|
||||
'medium recursive CTE with aliased columns' => [
|
||||
'name' => 'fibonacci',
|
||||
'query' => (new SQLSelect(['n' => 1, 'fib_n' => 0, 'next_fib_n' => 1]))->addUnion(
|
||||
new SQLSelect(['n + 1', 'next_fib_n', 'fib_n + next_fib_n'], 'fibonacci', ['n < 6']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => true,
|
||||
'selectFields' => '*',
|
||||
'selectFrom' => 'fibonacci',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['n' => 1, 'fib_n' => 0, 'next_fib_n' => 1],
|
||||
['n' => 2, 'fib_n' => 1, 'next_fib_n' => 1],
|
||||
['n' => 3, 'fib_n' => 1, 'next_fib_n' => 2],
|
||||
['n' => 4, 'fib_n' => 2, 'next_fib_n' => 3],
|
||||
['n' => 5, 'fib_n' => 3, 'next_fib_n' => 5],
|
||||
['n' => 6, 'fib_n' => 5, 'next_fib_n' => 8],
|
||||
],
|
||||
],
|
||||
// SQLSelect dedupes select fields. Because of that, for this test we have to start from a sequence
|
||||
// that doesn't select duplicate values - otherwise we end up selecting "1, 0" instead of "1, 0, 1"
|
||||
// in the main CTE select expression.
|
||||
'medium recursive CTE with explicit columns' => [
|
||||
'name' => 'fibonacci',
|
||||
'query' => (new SQLSelect([3, 1, 2]))->addUnion(
|
||||
new SQLSelect(['n + 1', 'next_fib_n', 'fib_n + next_fib_n'], 'fibonacci', ['n < 6']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => ['n', 'fib_n', 'next_fib_n'],
|
||||
'recursive' => true,
|
||||
'selectFields' => '*',
|
||||
'selectFrom' => 'fibonacci',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['n' => 3, 'fib_n' => 1, 'next_fib_n' => 2],
|
||||
['n' => 4, 'fib_n' => 2, 'next_fib_n' => 3],
|
||||
['n' => 5, 'fib_n' => 3, 'next_fib_n' => 5],
|
||||
['n' => 6, 'fib_n' => 5, 'next_fib_n' => 8],
|
||||
],
|
||||
],
|
||||
// Validate that we can have a CTE with multiple fields, while only using one field in the result set
|
||||
'medium recursive CTE selecting only one column in the result' => [
|
||||
'name' => 'fibonacci',
|
||||
'query' => (new SQLSelect(['n' => 1, 'fib_n' => 0, 'next_fib_n' => 1]))->addUnion(
|
||||
new SQLSelect(['n + 1', 'next_fib_n', 'fib_n + next_fib_n'], 'fibonacci', ['n < 6']),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => true,
|
||||
'selectFields' => 'fib_n',
|
||||
'selectFrom' => 'fibonacci',
|
||||
'extraManipulations' => [],
|
||||
'expected' => [
|
||||
['fib_n' => 0],
|
||||
['fib_n' => 1],
|
||||
['fib_n' => 1],
|
||||
['fib_n' => 2],
|
||||
['fib_n' => 3],
|
||||
['fib_n' => 5],
|
||||
],
|
||||
],
|
||||
// Using an actual database table, extrapolate missing data with a recursive query
|
||||
'complex recursive CTE with aliased columns' => [
|
||||
'name' => 'dates',
|
||||
'query' => (new SQLSelect(['date' => 'MIN("Date")'], "SQLSelectTestCteDates"))->addUnion(
|
||||
new SQLSelect(
|
||||
'date + INTERVAL 1 DAY',
|
||||
'dates',
|
||||
['date + INTERVAL 1 DAY <= (SELECT MAX("Date") FROM "SQLSelectTestCteDates")']
|
||||
),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => true,
|
||||
'selectFields' => ['dates.date', 'sum_price' => 'COALESCE(SUM("Price"), 0)'],
|
||||
'selectFrom' => 'dates',
|
||||
'extraManipulations' => [
|
||||
'addLeftJoin' => ['SQLSelectTestCteDates', 'dates.date = "SQLSelectTestCteDates"."Date"'],
|
||||
'addOrderBy' => ['dates.date'],
|
||||
'addGroupBy' => ['dates.date'],
|
||||
],
|
||||
'expected' => [
|
||||
['date' => '2017-01-03', 'sum_price' => 300],
|
||||
['date' => '2017-01-04', 'sum_price' => 0],
|
||||
['date' => '2017-01-05', 'sum_price' => 0],
|
||||
['date' => '2017-01-06', 'sum_price' => 50],
|
||||
['date' => '2017-01-07', 'sum_price' => 0],
|
||||
['date' => '2017-01-08', 'sum_price' => 180],
|
||||
['date' => '2017-01-09', 'sum_price' => 0],
|
||||
['date' => '2017-01-10', 'sum_price' => 5],
|
||||
],
|
||||
],
|
||||
'complex recursive CTE with explicit columns' => [
|
||||
'name' => 'dates',
|
||||
'query' => (new SQLSelect('MIN("Date")', "SQLSelectTestCteDates"))->addUnion(
|
||||
new SQLSelect(
|
||||
'date + INTERVAL 1 DAY',
|
||||
'dates',
|
||||
['date + INTERVAL 1 DAY <= (SELECT MAX("Date") FROM "SQLSelectTestCteDates")']
|
||||
),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => ['date'],
|
||||
'recursive' => true,
|
||||
'selectFields' => ['dates.date', 'sum_price' => 'COALESCE(SUM("Price"), 0)'],
|
||||
'selectFrom' => 'dates',
|
||||
'extraManipulations' => [
|
||||
'addLeftJoin' => ['SQLSelectTestCteDates', 'dates.date = "SQLSelectTestCteDates"."Date"'],
|
||||
'addOrderBy' => ['dates.date'],
|
||||
'addGroupBy' => ['dates.date'],
|
||||
],
|
||||
'expected' => [
|
||||
['date' => '2017-01-03', 'sum_price' => 300],
|
||||
['date' => '2017-01-04', 'sum_price' => 0],
|
||||
['date' => '2017-01-05', 'sum_price' => 0],
|
||||
['date' => '2017-01-06', 'sum_price' => 50],
|
||||
['date' => '2017-01-07', 'sum_price' => 0],
|
||||
['date' => '2017-01-08', 'sum_price' => 180],
|
||||
['date' => '2017-01-09', 'sum_price' => 0],
|
||||
['date' => '2017-01-10', 'sum_price' => 5],
|
||||
],
|
||||
],
|
||||
// Using an actual database table, get the ancestors of a given record with a recursive query
|
||||
'complex hierarchical CTE with aliased columns' => [
|
||||
'name' => 'hierarchy',
|
||||
'query' => (
|
||||
new SQLSelect(
|
||||
[
|
||||
'parent_id' => '"SQLSelectTestCteRecursive"."ParentID"',
|
||||
'sort_order' => 0,
|
||||
],
|
||||
"SQLSelectTestCteRecursive",
|
||||
[['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."Title" = ?' => 'child of child1']]
|
||||
)
|
||||
)->addUnion(
|
||||
new SQLSelect(
|
||||
[
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
'sort_order + 1',
|
||||
],
|
||||
// Note that we select both the CTE and the real table in the FROM statement.
|
||||
// We could also select one of these and JOIN on the other.
|
||||
['"hierarchy"', '"SQLSelectTestCteRecursive"'],
|
||||
['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"']
|
||||
),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => [],
|
||||
'recursive' => true,
|
||||
'selectFields' => ['"SQLSelectTestCteRecursive"."Title"'],
|
||||
'selectFrom' => '"SQLSelectTestCteRecursive"',
|
||||
'extraManipulations' => [
|
||||
'addInnerJoin' => ['hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"'],
|
||||
'setOrderBy' => ['sort_order', 'ASC'],
|
||||
],
|
||||
'expected' => [
|
||||
['Title' => 'child1'],
|
||||
['Title' => 'parent'],
|
||||
['Title' => 'grandparent'],
|
||||
],
|
||||
],
|
||||
'complex hierarchical CTE with explicit columns' => [
|
||||
'name' => 'hierarchy',
|
||||
'query' => (
|
||||
new SQLSelect(
|
||||
[
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
0
|
||||
],
|
||||
"SQLSelectTestCteRecursive",
|
||||
[['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."Title" = ?' => 'child of child1']]
|
||||
)
|
||||
)->addUnion(
|
||||
new SQLSelect(
|
||||
[
|
||||
'"SQLSelectTestCteRecursive"."ParentID"',
|
||||
'sort_order + 1'
|
||||
],
|
||||
['"hierarchy"', '"SQLSelectTestCteRecursive"'],
|
||||
['"SQLSelectTestCteRecursive"."ParentID" > 0 AND "SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"']
|
||||
),
|
||||
SQLSelect::UNION_ALL
|
||||
),
|
||||
'cteFields' => ['parent_id', 'sort_order'],
|
||||
'recursive' => true,
|
||||
'selectFields' => ['"SQLSelectTestCteRecursive"."Title"'],
|
||||
'selectFrom' => '"SQLSelectTestCteRecursive"',
|
||||
'extraManipulations' => [
|
||||
'addInnerJoin' => ['hierarchy', '"SQLSelectTestCteRecursive"."ID" = "hierarchy"."parent_id"'],
|
||||
'setOrderBy' => ['sort_order', 'ASC'],
|
||||
],
|
||||
'expected' => [
|
||||
['Title' => 'child1'],
|
||||
['Title' => 'parent'],
|
||||
['Title' => 'grandparent'],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideWith
|
||||
*/
|
||||
public function testWith(
|
||||
string $name,
|
||||
SQLSelect $query,
|
||||
array $cteFields,
|
||||
bool $recursive,
|
||||
string|array $selectFields,
|
||||
string|array $selectFrom,
|
||||
array $extraManipulations,
|
||||
array $expected
|
||||
) {
|
||||
if (!DB::get_conn()->supportsCteQueries()) {
|
||||
$this->markTestSkipped('The current database does not support WITH statements');
|
||||
}
|
||||
if ($recursive && !DB::get_conn()->supportsCteQueries(true)) {
|
||||
$this->markTestSkipped('The current database does not support recursive WITH statements');
|
||||
}
|
||||
|
||||
$select = new SQLSelect($selectFields, $selectFrom);
|
||||
$select->addWith($name, $query, $cteFields, $recursive);
|
||||
|
||||
foreach ($extraManipulations as $method => $args) {
|
||||
$select->$method(...$args);
|
||||
}
|
||||
|
||||
$this->assertEquals($expected, iterator_to_array($select->execute(), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that we can have multiple WITH statements for a given SQLSelect object, and that
|
||||
* subsequent WITH statements can refer to one another.
|
||||
*/
|
||||
public function testMultipleWith()
|
||||
{
|
||||
if (!DB::get_conn()->supportsCteQueries()) {
|
||||
$this->markTestSkipped('The current database does not support WITH statements');
|
||||
}
|
||||
|
||||
$cte1 = new SQLSelect('"SQLSelectTestCteDates"."Price"', "SQLSelectTestCteDates");
|
||||
$cte2 = new SQLSelect('"SQLSelectTestCteRecursive"."Title"', "SQLSelectTestCteRecursive");
|
||||
$cte3 = new SQLSelect(['price' => 'price', 'title' => 'title'], ['cte1', 'cte2']);
|
||||
|
||||
$select = new SQLSelect(['price', 'title'], 'cte3');
|
||||
$select->addWith('cte1', $cte1, ['price'])
|
||||
->addWith('cte2', $cte2, ['title'])
|
||||
->addWith('cte3', $cte3)
|
||||
->addOrderBy(['price', 'title']);
|
||||
|
||||
$expected = [];
|
||||
foreach (CteDatesObject::get()->sort('Price') as $priceRecord) {
|
||||
foreach (CteRecursiveObject::get()->sort('Title') as $titleRecord) {
|
||||
$expected[] = [
|
||||
'price' => $priceRecord->Price,
|
||||
'title' => $titleRecord->Title,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertEquals($expected, iterator_to_array($select->execute(), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests that a second WITH clause with a duplicate name triggers an exception.
|
||||
*/
|
||||
public function testMultipleWithDuplicateName()
|
||||
{
|
||||
if (!DB::get_conn()->supportsCteQueries()) {
|
||||
$this->markTestSkipped('The current database does not support WITH statements');
|
||||
}
|
||||
|
||||
$select = new SQLSelect();
|
||||
$select->addWith('cte', new SQLSelect());
|
||||
|
||||
$this->expectException(LogicException::class);
|
||||
$this->expectExceptionMessage('WITH clause with name \'cte\' already exists.');
|
||||
|
||||
$select->addWith('cte', new SQLSelect());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,3 +9,36 @@ SilverStripe\ORM\Tests\SQLSelectTest\TestObject:
|
|||
Meta: 'Details 2'
|
||||
Date: 2012-05-01 09:00:00
|
||||
Common: 'Common Value'
|
||||
|
||||
SilverStripe\ORM\Tests\SQLSelectTest\CteDatesObject:
|
||||
dates1:
|
||||
Date: '2017-01-03'
|
||||
Price: 300
|
||||
dates2:
|
||||
Date: '2017-01-06'
|
||||
Price: 50
|
||||
dates3:
|
||||
Date: '2017-01-08'
|
||||
Price: 180
|
||||
dates4:
|
||||
Date: '2017-01-10'
|
||||
Price: 5
|
||||
|
||||
SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject:
|
||||
recursive1:
|
||||
Title: 'grandparent'
|
||||
recursive2:
|
||||
Title: 'parent'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.recursive1
|
||||
recursive3:
|
||||
Title: 'child1'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.recursive2
|
||||
recursive4:
|
||||
Title: 'child2'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.recursive2
|
||||
recursive5:
|
||||
Title: 'child of child1'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.recursive3
|
||||
recursive6:
|
||||
Title: 'child of child2'
|
||||
Parent: =>SilverStripe\ORM\Tests\SQLSelectTest\CteRecursiveObject.recursive5
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\SQLSelectTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class CteDatesObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'SQLSelectTestCteDates';
|
||||
|
||||
private static $db = [
|
||||
'Date' => 'Date',
|
||||
'Price' => 'Int',
|
||||
];
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace SilverStripe\ORM\Tests\SQLSelectTest;
|
||||
|
||||
use SilverStripe\Dev\TestOnly;
|
||||
use SilverStripe\ORM\DataObject;
|
||||
|
||||
class CteRecursiveObject extends DataObject implements TestOnly
|
||||
{
|
||||
private static $table_name = 'SQLSelectTestCteRecursive';
|
||||
|
||||
private static $db = [
|
||||
'Title' => 'Varchar',
|
||||
];
|
||||
|
||||
private static $has_one = [
|
||||
'Parent' => self::class,
|
||||
];
|
||||
|
||||
private static $has_many = [
|
||||
'Children' => self::class . '.Parent',
|
||||
];
|
||||
}
|
|
@ -1896,4 +1896,35 @@ class MemberTest extends FunctionalTest
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testGenerateRandomPassword()
|
||||
{
|
||||
$member = new Member();
|
||||
// no password validator
|
||||
Member::set_password_validator(null);
|
||||
// password length is same as length argument
|
||||
$password = $member->generateRandomPassword(5);
|
||||
$this->assertSame(5, strlen($password));
|
||||
// default to 20 if not length argument
|
||||
$password = $member->generateRandomPassword();
|
||||
$this->assertSame(20, strlen($password));
|
||||
// password validator
|
||||
$validator = new PasswordValidator();
|
||||
Member::set_password_validator($validator);
|
||||
// Password length of 20 even if validator minLength is less than 20
|
||||
$validator->setMinLength(10);
|
||||
$password = $member->generateRandomPassword();
|
||||
$this->assertSame(20, strlen($password));
|
||||
// Password length of 25 if passing length argument, and validator minlength is less than length argument
|
||||
$password = $member->generateRandomPassword(25);
|
||||
$this->assertSame(25, strlen($password));
|
||||
// Password length is validator minLength if validator minLength is greater than 20 and no length argument
|
||||
$validator->setMinLength(30);
|
||||
$password = $member->generateRandomPassword();
|
||||
$this->assertSame(30, strlen($password));
|
||||
// Exception throw if length argument is less than validator minLength
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('length argument is less than password validator minLength');
|
||||
$password = $member->generateRandomPassword(15);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ namespace SilverStripe\Security\Tests;
|
|||
use SilverStripe\Security\PasswordEncryptor_Blowfish;
|
||||
use SilverStripe\Security\PasswordEncryptor;
|
||||
use SilverStripe\Core\Config\Config;
|
||||
use SilverStripe\Dev\Deprecation;
|
||||
use SilverStripe\Dev\SapphireTest;
|
||||
use SilverStripe\Security\PasswordEncryptor_LegacyPHPHash;
|
||||
use SilverStripe\Security\PasswordEncryptor_NotFoundException;
|
||||
|
@ -155,7 +156,7 @@ class PasswordEncryptorTest extends SapphireTest
|
|||
'encryptors',
|
||||
['test_sha1legacy' => [PasswordEncryptor_LegacyPHPHash::class => 'sha1']]
|
||||
);
|
||||
$e = PasswordEncryptor::create_for_algorithm('test_sha1legacy');
|
||||
$e = Deprecation::withNoReplacement(fn() => PasswordEncryptor::create_for_algorithm('test_sha1legacy'));
|
||||
// precomputed hashes for 'mypassword' from different architectures
|
||||
$amdHash = 'h1fj0a6m4o6k0sosks88oo08ko4gc4s';
|
||||
$intelHash = 'h1fj0a6m4o0g04ocg00o4kwoc4wowws';
|
||||
|
|
Loading…
Reference in New Issue