Compare commits

..

1 Commits

Author SHA1 Message Date
Guy Sartorelli
0f386039df
API Refactor template layer into its own module
Includes the following large-scale changes:
- Impoved barrier between model and view layers
- Improved casting of scalar to relevant DBField types
- Improved capabilities for rendering arbitrary data in templates
2024-10-11 11:31:00 +13:00
37 changed files with 150 additions and 1011 deletions

View File

@ -2,7 +2,6 @@
<?php
use SilverStripe\Cli\Sake;
use SilverStripe\ORM\DB;
// Ensure that people can't access this from a web-server
if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
@ -12,8 +11,5 @@ if (!in_array(PHP_SAPI, ['cli', 'cgi', 'cgi-fcgi'])) {
require_once __DIR__ . '/../src/includes/autoload.php';
// CLI scripts must only use the primary database connection and not replicas
DB::setMustUsePrimary();
$sake = new Sake();
$sake->run();

View File

@ -17,7 +17,6 @@ use SilverStripe\Versioned\Versioned;
use SilverStripe\View\Requirements;
use SilverStripe\View\Requirements_Backend;
use SilverStripe\View\TemplateGlobalProvider;
use SilverStripe\ORM\DB;
/**
* Director is responsible for processing URLs, and providing environment information.
@ -85,14 +84,6 @@ class Director implements TemplateGlobalProvider
*/
private static $default_base_url = '`SS_BASE_URL`';
/**
* List of routing rule patterns that must only use the primary database and not a replica
*/
private static array $rule_patterns_must_use_primary_db = [
'dev',
'Security',
];
public function __construct()
{
}
@ -305,18 +296,6 @@ class Director implements TemplateGlobalProvider
{
Injector::inst()->registerService($request, HTTPRequest::class);
// Check if primary database must be used based on request rules
// Note this check must happend before the rules are processed as
// $shiftOnSuccess param is passed as true in `$request->match($pattern, true)` later on in
// this method, which modifies `$this->dirParts`, thus affecting `$request->match($rule)` directly below
$primaryDbOnlyRules = Director::config()->uninherited('rule_patterns_must_use_primary_db');
foreach ($primaryDbOnlyRules as $rule) {
if ($request->match($rule)) {
DB::setMustUsePrimary();
break;
}
}
$rules = Director::config()->uninherited('rules');
$this->extend('updateRules', $rules);

View File

@ -85,7 +85,7 @@ class ClassInfo implements Flushable
public static function hasTable($tableName)
{
$cache = ClassInfo::getCache();
$configData = serialize(DB::getConfig(DB::CONN_PRIMARY));
$configData = serialize(DB::getConfig());
$cacheKey = 'tableList_' . md5($configData);
$tableList = $cache->get($cacheKey) ?? [];
if (empty($tableList) && DB::is_active()) {

View File

@ -7,14 +7,12 @@ use SilverStripe\Dev\Install\DatabaseAdapterRegistry;
use SilverStripe\ORM\Connect\NullDatabase;
use SilverStripe\ORM\DB;
use Exception;
use InvalidArgumentException;
/**
* Simple Kernel container
*/
class CoreKernel extends BaseKernel
{
protected bool $bootDatabase = true;
/**
@ -40,7 +38,7 @@ class CoreKernel extends BaseKernel
$this->flush = $flush;
if (!$this->bootDatabase) {
DB::set_conn(new NullDatabase(), DB::CONN_PRIMARY);
DB::set_conn(new NullDatabase());
}
$this->bootPHP();
@ -75,7 +73,7 @@ class CoreKernel extends BaseKernel
}
/**
* Load database configuration from the $database and $databaseConfig globals
* Load default database configuration from the $database and $databaseConfig globals
*/
protected function bootDatabaseGlobals()
{
@ -86,62 +84,41 @@ class CoreKernel extends BaseKernel
global $databaseConfig;
global $database;
// Ensure global database config has prefix and suffix applied
if (!empty($databaseConfig) && !empty($database)) {
$databaseConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
}
// Set config for primary and any replicas
for ($i = 0; $i <= DB::MAX_REPLICAS; $i++) {
if ($i === 0) {
$name = DB::CONN_PRIMARY;
} else {
$name = DB::getReplicaConfigKey($i);
if (!DB::hasConfig($name)) {
break;
}
// Case 1: $databaseConfig global exists. Merge $database in as needed
if (!empty($databaseConfig)) {
if (!empty($database)) {
$databaseConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
}
// Case 1: $databaseConfig global exists
// Only set it if its valid, otherwise ignore $databaseConfig entirely
if (!empty($databaseConfig) && !empty($databaseConfig['database'])) {
DB::setConfig($databaseConfig, $name);
if (!empty($databaseConfig['database'])) {
DB::setConfig($databaseConfig);
return;
}
}
// Case 2: $databaseConfig global does not exist
// Merge $database global into existing config
if (!empty($database)) {
$dbConfig = DB::getConfig($name);
$dbConfig['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
DB::setConfig($dbConfig, $name);
}
// Case 2: $database merged into existing config
if (!empty($database)) {
$existing = DB::getConfig();
$existing['database'] = $this->getDatabasePrefix() . $database . $this->getDatabaseSuffix();
DB::setConfig($existing);
}
}
/**
* Load database configuration from environment variables
* Load default database configuration from environment variable
*/
protected function bootDatabaseEnvVars()
{
if (!$this->bootDatabase) {
return;
}
// Set primary database config
// Set default database config
$databaseConfig = $this->getDatabaseConfig();
$databaseConfig['database'] = $this->getDatabaseName();
DB::setConfig($databaseConfig, DB::CONN_PRIMARY);
// Set database replicas config
for ($i = 1; $i <= DB::MAX_REPLICAS; $i++) {
$envKey = $this->getReplicaEnvKey('SS_DATABASE_SERVER', $i);
if (!Environment::hasEnv($envKey)) {
break;
}
$replicaDatabaseConfig = $this->getDatabaseReplicaConfig($i);
$configKey = DB::getReplicaConfigKey($i);
DB::setConfig($replicaDatabaseConfig, $configKey);
}
DB::setConfig($databaseConfig);
}
/**
@ -150,72 +127,12 @@ class CoreKernel extends BaseKernel
* @return array
*/
protected function getDatabaseConfig()
{
return $this->getSingleDataBaseConfig(0);
}
private function getDatabaseReplicaConfig(int $replica)
{
if ($replica <= 0) {
throw new InvalidArgumentException('Replica number must be greater than 0');
}
return $this->getSingleDataBaseConfig($replica);
}
/**
* Convert a database key to a replica key
* e.g. SS_DATABASE_SERVER -> SS_DATABASE_SERVER_REPLICA_01
*
* @param string $key - The key to look up in the environment
* @param int $replica - Replica number
*/
private function getReplicaEnvKey(string $key, int $replica): string
{
if ($replica <= 0) {
throw new InvalidArgumentException('Replica number must be greater than 0');
}
// Do not allow replicas to define keys that could lead to unexpected behaviour if
// they do not match the primary database configuration
if (in_array($key, ['SS_DATABASE_CLASS', 'SS_DATABASE_NAME', 'SS_DATABASE_CHOOSE_NAME'])) {
return $key;
}
// Left pad replica number with a zeros to match the length of the maximum replica number
$len = strlen((string) DB::MAX_REPLICAS);
return $key . '_REPLICA_' . str_pad($replica, $len, '0', STR_PAD_LEFT);
}
/**
* Reads a single database configuration variable from the environment
* For replica databases, it will first attempt to find replica-specific configuration
* before falling back to the primary configuration.
*
* Replicate specific configuration has `_REPLICA_01` appended to the key
* where 01 is the replica number.
*
* @param string $key - The key to look up in the environment
* @param int $replica - Replica number. Passing 0 will return the primary database configuration
*/
private function getDatabaseConfigVariable(string $key, int $replica): string
{
if ($replica > 0) {
$key = $this->getReplicaEnvKey($key, $replica);
}
if (Environment::hasEnv($key)) {
return Environment::getEnv($key);
}
return '';
}
/**
* @param int $replica - Replica number. Passing 0 will return the primary database configuration
*/
private function getSingleDataBaseConfig(int $replica): array
{
$databaseConfig = [
"type" => $this->getDatabaseConfigVariable('SS_DATABASE_CLASS', $replica) ?: 'MySQLDatabase',
"server" => $this->getDatabaseConfigVariable('SS_DATABASE_SERVER', $replica) ?: 'localhost',
"username" => $this->getDatabaseConfigVariable('SS_DATABASE_USERNAME', $replica) ?: null,
"password" => $this->getDatabaseConfigVariable('SS_DATABASE_PASSWORD', $replica) ?: null,
"type" => Environment::getEnv('SS_DATABASE_CLASS') ?: 'MySQLDatabase',
"server" => Environment::getEnv('SS_DATABASE_SERVER') ?: 'localhost',
"username" => Environment::getEnv('SS_DATABASE_USERNAME') ?: null,
"password" => Environment::getEnv('SS_DATABASE_PASSWORD') ?: null,
];
// Only add SSL keys in the array if there is an actual value associated with them
@ -226,7 +143,7 @@ class CoreKernel extends BaseKernel
'ssl_cipher' => 'SS_DATABASE_SSL_CIPHER',
];
foreach ($sslConf as $key => $envVar) {
$envValue = $this->getDatabaseConfigVariable($envVar, $replica);
$envValue = Environment::getEnv($envVar);
if ($envValue) {
$databaseConfig[$key] = $envValue;
}
@ -242,25 +159,25 @@ class CoreKernel extends BaseKernel
}
// Set the port if called for
$dbPort = $this->getDatabaseConfigVariable('SS_DATABASE_PORT', $replica);
$dbPort = Environment::getEnv('SS_DATABASE_PORT');
if ($dbPort) {
$databaseConfig['port'] = $dbPort;
}
// Set the timezone if called for
$dbTZ = $this->getDatabaseConfigVariable('SS_DATABASE_TIMEZONE', $replica);
$dbTZ = Environment::getEnv('SS_DATABASE_TIMEZONE');
if ($dbTZ) {
$databaseConfig['timezone'] = $dbTZ;
}
// For schema enabled drivers:
$dbSchema = $this->getDatabaseConfigVariable('SS_DATABASE_SCHEMA', $replica);
$dbSchema = Environment::getEnv('SS_DATABASE_SCHEMA');
if ($dbSchema) {
$databaseConfig["schema"] = $dbSchema;
}
// For SQlite3 memory databases (mainly for testing purposes)
$dbMemory = $this->getDatabaseConfigVariable('SS_DATABASE_MEMORY', $replica);
$dbMemory = Environment::getEnv('SS_DATABASE_MEMORY');
if ($dbMemory) {
$databaseConfig["memory"] = $dbMemory;
}
@ -288,7 +205,6 @@ class CoreKernel extends BaseKernel
/**
* Get name of database
* Note that any replicas must have the same database name as the primary database
*
* @return string
*/

View File

@ -48,7 +48,7 @@ use ReflectionClass;
use SilverStripe\Dev\Exceptions\ExpectedErrorException;
use SilverStripe\Dev\Exceptions\ExpectedNoticeException;
use SilverStripe\Dev\Exceptions\ExpectedWarningException;
use SilverStripe\ORM\DB;
use SilverStripe\Dev\Exceptions\UnexpectedErrorException;
/**
* Test case class for the Silverstripe framework.
@ -434,9 +434,6 @@ abstract class SapphireTest extends TestCase implements TestOnly
*/
public static function setUpBeforeClass(): void
{
// Disallow the use of DB replicas in tests
DB::setMustUsePrimary();
// Start tests
static::start();

View File

@ -4,7 +4,6 @@ namespace SilverStripe\Dev;
use Exception;
use InvalidArgumentException;
use LogicException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Cookie_Backend;
use SilverStripe\Control\Director;
@ -215,7 +214,7 @@ class TestSession
$formCrawler = $page->filterXPath("//form[@id='$formID']");
$form = $formCrawler->form();
} catch (InvalidArgumentException $e) {
throw new LogicException("TestSession::submitForm failed to find the form '{$formID}'");
user_error("TestSession::submitForm failed to find the form {$formID}");
}
foreach ($data as $fieldName => $value) {
@ -236,7 +235,7 @@ class TestSession
if ($button) {
$btnXpath = "//button[@name='$button'] | //input[@name='$button'][@type='button' or @type='submit']";
if (!$formCrawler->children()->filterXPath($btnXpath)->count()) {
throw new LogicException("Can't find button '$button' to submit as part of test.");
throw new Exception("Can't find button '$button' to submit as part of test.");
}
$values[$button] = true;
}

View File

@ -49,8 +49,6 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
*/
protected ?string $searchField = null;
private string $placeHolderText = '';
/**
* @inheritDoc
*/
@ -247,24 +245,6 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
return false;
}
/**
* Get the text to be used as a placeholder in the search field.
* If blank, the placeholder will be generated based on the class held in the GridField.
*/
public function getPlaceHolderText(): string
{
return $this->placeHolderText;
}
/**
* Set the text to be used as a placeholder in the search field.
* If blank, this text will be generated based on the class held in the GridField.
*/
public function setPlaceHolderText(string $placeHolderText): static
{
$this->placeHolderText = $placeHolderText;
return $this;
}
/**
* Generate a search context based on the model class of the of the GridField
@ -338,7 +318,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
$schema = [
'formSchemaUrl' => $schemaUrl,
'name' => $searchField,
'placeholder' => $this->getPlaceHolder($inst),
'placeholder' => _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $this->getTitle($gridField, $inst)]),
'filters' => $filters ?: new \stdClass, // stdClass maps to empty json object '{}'
'gridfield' => $gridField->getName(),
'searchAction' => $searchAction->getAttribute('name'),
@ -350,6 +330,19 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
return json_encode($schema);
}
private function getTitle(GridField $gridField, object $inst): string
{
if ($gridField->Title) {
return $gridField->Title;
}
if (ClassInfo::hasMethod($inst, 'i18n_plural_name')) {
return $inst->i18n_plural_name();
}
return ClassInfo::shortName($inst);
}
/**
* Returns the search form for the component
*
@ -393,7 +386,7 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
$field->addExtraClass('stacked no-change-track');
}
$name = $this->getTitle(singleton($gridField->getModelClass()));
$name = $this->getTitle($gridField, singleton($gridField->getModelClass()));
$this->searchForm = $form = new Form(
$gridField,
@ -463,32 +456,4 @@ class GridFieldFilterHeader extends AbstractGridFieldComponent implements GridFi
)
];
}
/**
* Get the text that will be used as a placeholder in the search field.
*
* @param object $obj An instance of the class that will be searched against.
* If getPlaceHolderText is empty, this object will be used to build the placeholder
* e.g. 'Search "My Data Object"'
*/
private function getPlaceHolder(object $obj): string
{
$placeholder = $this->getPlaceHolderText();
if (!empty($placeholder)) {
return $placeholder;
}
if ($obj) {
return _t(__CLASS__ . '.Search', 'Search "{name}"', ['name' => $this->getTitle($obj)]);
}
return _t(__CLASS__ . '.Search_Default', 'Search');
}
private function getTitle(object $inst): string
{
if (ClassInfo::hasMethod($inst, 'i18n_plural_name')) {
return $inst->i18n_plural_name();
}
return ClassInfo::shortName($inst);
}
}

View File

@ -4,7 +4,6 @@ namespace SilverStripe\Model;
use SilverStripe\Core\ArrayLib;
use InvalidArgumentException;
use JsonSerializable;
use stdClass;
/**
@ -17,9 +16,13 @@ use stdClass;
* ));
* </code>
*/
class ArrayData extends ModelData implements JsonSerializable
class ArrayData extends ModelData
{
protected array $array;
/**
* @var array
* @see ArrayData::_construct()
*/
protected $array;
/**
* @param object|array $value An associative array, or an object with simple properties.
@ -48,8 +51,10 @@ class ArrayData extends ModelData implements JsonSerializable
/**
* Get the source array
*
* @return array
*/
public function toMap(): array
public function toMap()
{
return $this->array;
}
@ -102,11 +107,6 @@ class ArrayData extends ModelData implements JsonSerializable
return !empty($this->array);
}
public function jsonSerialize(): array
{
return $this->array;
}
/**
* Converts an associative array to a simple object
*

View File

@ -43,7 +43,7 @@ class TempDatabase
*
* @param string $name DB Connection name to use
*/
public function __construct($name = DB::CONN_PRIMARY)
public function __construct($name = 'default')
{
$this->name = $name;
}

View File

@ -3,7 +3,6 @@
namespace SilverStripe\ORM;
use InvalidArgumentException;
use RunTimeException;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config;
@ -22,22 +21,6 @@ use SilverStripe\ORM\Queries\SQLExpression;
*/
class DB
{
/**
* A dynamic connection that will use either a replica connection (if one is
* available and not forced to use the 'primary' connection), or the 'primary' connection
*/
public const CONN_DYNAMIC = 'dynamic';
/**
* The 'primary' connection name, which is the main database connection and is used for all write
* operations and for read operations when the 'dynamic' connection is forced to use the 'primary' connection
*/
public const CONN_PRIMARY = 'primary';
/**
* The maximum number of replicas databases that can be configured
*/
public const MAX_REPLICAS = 99;
/**
* This constant was added in SilverStripe 2.4 to indicate that SQL-queries
@ -75,47 +58,19 @@ class DB
*/
protected static $configs = [];
/**
* The last SQL query run.
* @var string
*/
public static $lastQuery;
/**
* The name of the last connection used. This is only used for unit-testing purposes.
* @interal
*/
private static string $lastConnectionName = '';
/**
* Internal flag to keep track of when db connection was attempted.
*
* @internal
*/
private static $connection_attempted = false;
/**
* Only use the primary database connection for the rest of the current request
*
* @internal
*/
private static bool $mustUsePrimary = false;
/**
* Used by DB::withPrimary() to count the number of times it has been called
* Uses an int instead of a bool to allow for nested calls
*
* @internal
*/
private static int $withPrimaryCount = 0;
/**
* The key of the replica config to use for this request
*
* @internal
*/
private static string $replicaConfigKey = '';
/**
* Set the global database connection.
* Pass an object that's a subclass of SS_Database. This object will be used when {@link DB::query()}
@ -123,11 +78,11 @@ class DB
*
* @param Database $connection The connection object to set as the connection.
* @param string $name The name to give to this connection. If you omit this argument, the connection
* will be the primary one used by the ORM. However, you can store other named connections to
* will be the default one used by the ORM. However, you can store other named connections to
* be accessed through DB::get_conn($name). This is useful when you have an application that
* needs to connect to more than one database.
*/
public static function set_conn(Database $connection, $name)
public static function set_conn(Database $connection, $name = 'default')
{
DB::$connections[$name] = $connection;
}
@ -137,17 +92,11 @@ class DB
*
* @param string $name An optional name given to a connection in the DB::setConn() call. If omitted,
* the default connection is returned.
* @return Database|null
* @return Database
*/
public static function get_conn($name = DB::CONN_DYNAMIC)
public static function get_conn($name = 'default')
{
// Allow default to connect to replica if configured
if ($name === DB::CONN_DYNAMIC) {
$name = DB::getDynamicConnectionName();
}
if (isset(DB::$connections[$name])) {
DB::$lastConnectionName = $name;
return DB::$connections[$name];
}
@ -160,50 +109,14 @@ class DB
return null;
}
/**
* Whether the primary database connection will be used if the database is used right now
*/
public static function willUsePrimary(): bool
{
return DB::$mustUsePrimary || DB::$withPrimaryCount > 0 || !DB::hasReplicaConfig();
}
/**
* Set to use the primary database connection for rest of the current request
* meaning that replia connections will no longer be used
*
* This intentioally does not have a parameter to set this back to false, as this it to prevent
* accidentally attempting writing to a replica, or reading from an out of date replica
* after a write
*/
public static function setMustUsePrimary(): void
{
DB::$mustUsePrimary = true;
}
/**
* Only use the primary database connection when calling $callback
* Use this when doing non-mutable queries on the primary database where querying
* an out of sync replica could cause issues
* There's no need to use this with mutable queries, or after calling a mutable query
* as the primary database connection will be automatically used
*/
public static function withPrimary(callable $callback): mixed
{
DB::$withPrimaryCount++;
$result = $callback();
DB::$withPrimaryCount--;
return $result;
}
/**
* Retrieves the schema manager for the current database
*
* @param string $name An optional name given to a connection in the DB::setConn() call.
* If omitted, a dynamic connection is returned.
* @return DBSchemaManager|null
* @param string $name An optional name given to a connection in the DB::setConn() call. If omitted,
* the default connection is returned.
* @return DBSchemaManager
*/
public static function get_schema($name = DB::CONN_DYNAMIC)
public static function get_schema($name = 'default')
{
$connection = DB::get_conn($name);
if ($connection) {
@ -217,11 +130,11 @@ class DB
*
* @param SQLExpression $expression The expression object to build from
* @param array $parameters Out parameter for the resulting query parameters
* @param string $name An optional name given to a connection in the DB::setConn() call.
* If omitted, a dynamic connection is returned.
* @return string|null The resulting SQL as a string
* @param string $name An optional name given to a connection in the DB::setConn() call. If omitted,
* the default connection is returned.
* @return string The resulting SQL as a string
*/
public static function build_sql(SQLExpression $expression, &$parameters, $name = DB::CONN_DYNAMIC)
public static function build_sql(SQLExpression $expression, &$parameters, $name = 'default')
{
$connection = DB::get_conn($name);
if ($connection) {
@ -235,11 +148,11 @@ class DB
/**
* Retrieves the connector object for the current database
*
* @param string $name An optional name given to a connection in the DB::setConn() call.
* If omitted, a dynamic connection is returned.
* @return DBConnector|null
* @param string $name An optional name given to a connection in the DB::setConn() call. If omitted,
* the default connection is returned.
* @return DBConnector
*/
public static function get_connector($name = DB::CONN_DYNAMIC)
public static function get_connector($name = 'default')
{
$connection = DB::get_conn($name);
if ($connection) {
@ -355,13 +268,8 @@ class DB
* @param string $label identifier for the connection
* @return Database
*/
public static function connect($databaseConfig, $label = DB::CONN_DYNAMIC)
public static function connect($databaseConfig, $label = 'default')
{
// Allow default to connect to replica if configured
if ($label === DB::CONN_DYNAMIC) {
$label = DB::getDynamicConnectionName();
}
// This is used by the "testsession" module to test up a test session using an alternative name
if ($name = DB::get_alternative_database_name()) {
$databaseConfig['database'] = $name;
@ -380,7 +288,6 @@ class DB
$conn = Injector::inst()->create($dbClass);
DB::set_conn($conn, $label);
$conn->connect($databaseConfig);
DB::$lastConnectionName = $label;
return $conn;
}
@ -391,7 +298,7 @@ class DB
* @param array $databaseConfig
* @param string $name
*/
public static function setConfig($databaseConfig, $name = DB::CONN_PRIMARY)
public static function setConfig($databaseConfig, $name = 'default')
{
static::$configs[$name] = $databaseConfig;
}
@ -402,42 +309,13 @@ class DB
* @param string $name
* @return mixed
*/
public static function getConfig($name = DB::CONN_PRIMARY)
public static function getConfig($name = 'default')
{
if (static::hasConfig($name)) {
if (isset(static::$configs[$name])) {
return static::$configs[$name];
}
}
/**
* Check if a named connection config exists
*/
public static function hasConfig($name = DB::CONN_PRIMARY): bool
{
return array_key_exists($name, static::$configs);
}
/**
* Get a replica database configuration key
* e.g. replica_01
*/
public static function getReplicaConfigKey(int $replica): string
{
$len = strlen((string) DB::MAX_REPLICAS);
return 'replica_' . str_pad($replica, $len, '0', STR_PAD_LEFT);
}
/**
* Check if there are any replica configurations
*/
public static function hasReplicaConfig(): bool
{
$configKeys = array_keys(static::$configs);
return !empty(array_filter($configKeys, function (string $key) {
return (bool) preg_match('#^replica_[0-9]+$#', $key);
}));
}
/**
* Returns true if a database connection has been attempted.
* In particular, it lets the caller know if we're still so early in the execution pipeline that
@ -457,8 +335,8 @@ class DB
public static function query($sql, $errorLevel = E_USER_ERROR)
{
DB::$lastQuery = $sql;
$name = DB::getDynamicConnectionName($sql);
return DB::get_conn($name)->query($sql, $errorLevel);
return DB::get_conn()->query($sql, $errorLevel);
}
/**
@ -549,8 +427,8 @@ class DB
public static function prepared_query($sql, $parameters, $errorLevel = E_USER_ERROR)
{
DB::$lastQuery = $sql;
$name = DB::getDynamicConnectionName($sql);
return DB::get_conn($name)->preparedQuery($sql, $parameters, $errorLevel);
return DB::get_conn()->preparedQuery($sql, $parameters, $errorLevel);
}
/**
@ -802,63 +680,4 @@ class DB
{
DB::get_schema()->alterationMessage($message, $type);
}
/**
* Get the name of the database connection to use for the given SQL query
* The 'dynamic' connection can be either the primary or a replica connection if configured
*/
private static function getDynamicConnectionName(string $sql = ''): string
{
if (DB::willUsePrimary()) {
return DB::CONN_PRIMARY;
}
if (DB::isMutableSql($sql)) {
DB::$mustUsePrimary = true;
return DB::CONN_PRIMARY;
}
if (DB::$replicaConfigKey) {
return DB::$replicaConfigKey;
}
$name = DB::getRandomReplicaConfigKey();
DB::$replicaConfigKey = $name;
return $name;
}
/**
* Check if the given SQL query is mutable
*/
private static function isMutableSql(string $sql): bool
{
$dbClass = DB::getConfig(DB::CONN_PRIMARY)['type'];
// This must use getServiceSpec() and not Injector::get/create() followed by
// getConnector() as this can remove the dbConn from a different connection
// under edge case conditions
$dbSpec = Injector::inst()->getServiceSpec($dbClass);
$connectorService = $dbSpec['properties']['connector'];
$connector = Injector::inst()->convertServiceProperty($connectorService);
return $connector->isQueryMutable($sql);
}
/**
* Get a random replica database configuration key from the available replica configurations
* The replica choosen will be used for the rest of the request, unless the primary connection
* is forced
*/
private static function getRandomReplicaConfigKey(): string
{
$replicaNumbers = [];
for ($i = 1; $i <= DB::MAX_REPLICAS; $i++) {
$replicaKey = DB::getReplicaConfigKey($i);
if (!DB::hasConfig($replicaKey)) {
break;
}
$replicaNumbers[] = $i;
}
if (count($replicaNumbers) === 0) {
throw new RunTimeException('No replica configurations found');
}
// Choose a random replica
$index = rand(0, count($replicaNumbers) - 1);
return DB::getReplicaConfigKey($replicaNumbers[$index]);
}
}

View File

@ -238,7 +238,7 @@ class DataList extends ModelData implements SS_List, Filterable, Sortable, Limit
*/
public function sql(&$parameters = [])
{
return $this->dataQuery->sql($parameters);
return $this->dataQuery->query()->sql($parameters);
}
/**
@ -1041,7 +1041,7 @@ class DataList extends ModelData implements SS_List, Filterable, Sortable, Limit
private function executeQuery(): Query
{
$query = $this->dataQuery->execute();
$query = $this->dataQuery->query()->execute();
$this->fetchEagerLoadRelations($query);
return $query;
}

View File

@ -142,14 +142,6 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
*/
private static $default_classname = null;
/**
* Whether this DataObject class must only use the primary database and not a read-only replica
* Note that this will be only be enforced when using DataQuery::execute() or
* another method that uses calls DataQuery::execute() internally e.g. DataObject::get()
* This will not be enforced when using low-level ORM functionality to query data e.g. SQLSelect or DB::query()
*/
private static bool $must_use_primary_db = false;
/**
* Data stored in this objects database record. An array indexed by fieldname.
*

View File

@ -10,7 +10,6 @@ use SilverStripe\ORM\Connect\Query;
use SilverStripe\ORM\Queries\SQLConditionGroup;
use SilverStripe\ORM\Queries\SQLSelect;
use InvalidArgumentException;
use SilverStripe\Core\Config\Config;
/**
* An object representing a query of data from the DataObject's supporting database.
@ -450,9 +449,7 @@ class DataQuery
*/
public function execute()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->execute()
);
return $this->getFinalisedQuery()->execute();
}
/**
@ -475,9 +472,7 @@ class DataQuery
public function count()
{
$quotedColumn = DataObject::getSchema()->sqlColumnForField($this->dataClass(), 'ID');
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->count("DISTINCT {$quotedColumn}")
);
return $this->getFinalisedQuery()->count("DISTINCT {$quotedColumn}");
}
/**
@ -506,9 +501,7 @@ class DataQuery
// Wrap the whole thing in an "EXISTS"
$sql = 'SELECT CASE WHEN EXISTS(' . $statement->sql($params) . ') THEN 1 ELSE 0 END';
$result = $this->withCorrectDatabase(
fn() => DB::prepared_query($sql, $params)
);
$result = DB::prepared_query($sql, $params);
$row = $result->record();
$result = reset($row);
@ -589,9 +582,7 @@ class DataQuery
*/
public function aggregate($expression)
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->aggregate($expression)->execute()->value()
);
return $this->getFinalisedQuery()->aggregate($expression)->execute()->value();
}
/**
@ -602,9 +593,7 @@ class DataQuery
*/
public function firstRow()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->firstRow()
);
return $this->getFinalisedQuery()->firstRow();
}
/**
@ -615,9 +604,7 @@ class DataQuery
*/
public function lastRow()
{
return $this->withCorrectDatabase(
fn() => $this->getFinalisedQuery()->lastRow()
);
return $this->getFinalisedQuery()->lastRow();
}
/**
@ -1357,9 +1344,7 @@ class DataQuery
$query->selectField($fieldExpression, $field);
$this->ensureSelectContainsOrderbyColumns($query, $originalSelect);
return $this->withCorrectDatabase(
fn() => $query->execute()->column($field)
);
return $query->execute()->column($field);
}
/**
@ -1510,16 +1495,4 @@ class DataQuery
return $updated;
}
/**
* Calls a callback on either the primary database or a replica, with respect to the configured
* value of `must_use_primary_db` on the current dataClass
*/
private function withCorrectDatabase(callable $callback): mixed
{
if (Config::inst()->get($this->dataClass(), 'must_use_primary_db')) {
return DB::withPrimary($callback);
}
return $callback();
}
}

View File

@ -88,8 +88,6 @@ class Group extends DataObject
private static $table_name = "Group";
private static bool $must_use_primary_db = true;
private static $indexes = [
'Title' => true,
'Code' => true,

View File

@ -50,8 +50,6 @@ class LoginAttempt extends DataObject
private static $table_name = "LoginAttempt";
private static bool $must_use_primary_db = true;
/**
* @param bool $includerelations Indicate if the labels returned include relation fields
* @return array

View File

@ -105,8 +105,6 @@ class Member extends DataObject
private static $table_name = "Member";
private static bool $must_use_primary_db = true;
private static $default_sort = '"Surname", "FirstName"';
private static $indexes = [

View File

@ -27,8 +27,6 @@ class MemberPassword extends DataObject
private static $table_name = "MemberPassword";
private static bool $must_use_primary_db = true;
/**
* Log a password change from the given member.
* Call MemberPassword::log($this) from within Member whenever the password is changed.

View File

@ -46,8 +46,6 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
private static $table_name = "Permission";
private static bool $must_use_primary_db = true;
/**
* This is the value to use for the "Type" field if a permission should be
* granted.
@ -235,15 +233,15 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
}
// Raw SQL for efficiency
$permission = DB::withPrimary(fn() => DB::prepared_query(
$permission = DB::prepared_query(
"SELECT \"ID\"
FROM \"Permission\"
WHERE (
\"Code\" IN ($codeClause $adminClause)
AND \"Type\" = ?
AND \"GroupID\" IN ($groupClause)
$argClause
)",
FROM \"Permission\"
WHERE (
\"Code\" IN ($codeClause $adminClause)
AND \"Type\" = ?
AND \"GroupID\" IN ($groupClause)
$argClause
)",
array_merge(
$codeParams,
$adminParams,
@ -251,7 +249,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
$groupParams,
$argParams
)
)->value());
)->value();
if ($permission) {
return $permission;
@ -259,15 +257,15 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
// Strict checking disabled?
if (!static::config()->strict_checking || !$strict) {
$hasPermission = DB::withPrimary(fn() => DB::prepared_query(
$hasPermission = DB::prepared_query(
"SELECT COUNT(*)
FROM \"Permission\"
WHERE (
\"Code\" IN ($codeClause) AND
\"Type\" = ?
)",
FROM \"Permission\"
WHERE (
\"Code\" IN ($codeClause) AND
\"Type\" = ?
)",
array_merge($codeParams, [Permission::GRANT_PERMISSION])
)->value());
)->value();
if (!$hasPermission) {
return false;
@ -290,29 +288,25 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
if ($groupList) {
$groupCSV = implode(", ", $groupList);
$allowed = array_unique(
DB::withPrimary(fn() => DB::query("
SELECT \"Code\"
FROM \"Permission\"
WHERE \"Type\" = " . Permission::GRANT_PERMISSION . " AND \"GroupID\" IN ($groupCSV)
$allowed = array_unique(DB::query("
SELECT \"Code\"
FROM \"Permission\"
WHERE \"Type\" = " . Permission::GRANT_PERMISSION . " AND \"GroupID\" IN ($groupCSV)
UNION
UNION
SELECT \"Code\"
FROM \"PermissionRoleCode\" PRC
INNER JOIN \"PermissionRole\" PR ON PRC.\"RoleID\" = PR.\"ID\"
INNER JOIN \"Group_Roles\" GR ON GR.\"PermissionRoleID\" = PR.\"ID\"
WHERE \"GroupID\" IN ($groupCSV)
"))->column() ?? []
);
SELECT \"Code\"
FROM \"PermissionRoleCode\" PRC
INNER JOIN \"PermissionRole\" PR ON PRC.\"RoleID\" = PR.\"ID\"
INNER JOIN \"Group_Roles\" GR ON GR.\"PermissionRoleID\" = PR.\"ID\"
WHERE \"GroupID\" IN ($groupCSV)
")->column() ?? []);
$denied = array_unique(
DB::withPrimary(fn() => DB::query("
SELECT \"Code\"
FROM \"Permission\"
WHERE \"Type\" = " . Permission::DENY_PERMISSION . " AND \"GroupID\" IN ($groupCSV)
"))->column() ?? []
);
$denied = array_unique(DB::query("
SELECT \"Code\"
FROM \"Permission\"
WHERE \"Type\" = " . Permission::DENY_PERMISSION . " AND \"GroupID\" IN ($groupCSV)
")->column() ?? []);
return array_diff($allowed ?? [], $denied);
}
@ -590,9 +584,7 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
$flatCodeArray[] = $code;
}
}
$otherPerms = DB::withPrimary(
fn() => DB::query("SELECT DISTINCT \"Code\" From \"Permission\" WHERE \"Code\" != ''")->column()
);
$otherPerms = DB::query("SELECT DISTINCT \"Code\" From \"Permission\" WHERE \"Code\" != ''")->column();
if ($otherPerms) {
foreach ($otherPerms as $otherPerm) {

View File

@ -40,8 +40,6 @@ class PermissionRole extends DataObject
private static $table_name = "PermissionRole";
private static bool $must_use_primary_db = true;
private static $default_sort = '"Title"';
private static $singular_name = 'Role';

View File

@ -23,8 +23,6 @@ class PermissionRoleCode extends DataObject
];
private static $table_name = "PermissionRoleCode";
private static bool $must_use_primary_db = true;
private static $indexes = [
"Code" => true,

View File

@ -44,8 +44,6 @@ class RememberLoginHash extends DataObject
private static $table_name = "RememberLoginHash";
private static bool $must_use_primary_db = true;
/**
* Determines if logging out on one device also clears existing login tokens
* on all other devices owned by the member.

View File

@ -437,12 +437,6 @@ class Security extends Controller implements TemplateGlobalProvider
*/
public static function setCurrentUser($currentUser = null)
{
// Always use the primary database and not a replica if a CMS user is logged in
// This is to ensure that when viewing content on the frontend it is always
// up to date i.e. not from an unsynced replica
if ($currentUser && Permission::checkMember($currentUser, 'CMS_ACCESS')) {
DB::setMustUsePrimary();
}
Security::$currentUser = $currentUser;
}

View File

@ -1026,9 +1026,10 @@ class SSTemplateParser extends Parser implements TemplateParser
'arguments only.', $this);
}
// loop without arguments loops on the current scope
//loop without arguments loops on the current scope
if ($res['ArgumentCount'] == 0) {
$on = '$scope->locally()->self()';
$type = ViewLayerData::TYPE_METHOD;
$on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')";
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {

View File

@ -4263,9 +4263,10 @@ class SSTemplateParser extends Parser implements TemplateParser
'arguments only.', $this);
}
// loop without arguments loops on the current scope
//loop without arguments loops on the current scope
if ($res['ArgumentCount'] == 0) {
$on = '$scope->locally()->self()';
$type = ViewLayerData::TYPE_METHOD;
$on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')";
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {

View File

@ -376,6 +376,10 @@ class SSViewer_Scope
}
}
// if ($retval instanceof DBField) {
// $retval = $retval->getValue(); // Workaround because we're still calling obj in ViewLayerData
// }
$this->resetLocalScope();
return $retval;
}

View File

@ -9,7 +9,6 @@ use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Model\ModelData;
use SilverStripe\Model\ModelDataCustomised;
use SilverStripe\ORM\FieldType\DBClassName;
use Stringable;
use Traversable;
@ -23,16 +22,6 @@ class ViewLayerData implements IteratorAggregate, Stringable
public const TYPE_ANY = 'any';
/**
* Special variable names that can be used to get metadata about values
*/
public const META_DATA_NAMES = [
// Gets a DBClassName with the class name of $this->data
'ClassName',
// Returns $this->data
'Me',
];
private object $data;
public function __construct(mixed $data, mixed $source = null, string $name = '')
@ -93,8 +82,7 @@ class ViewLayerData implements IteratorAggregate, Stringable
{
// Note we explicitly DO NOT call count() or exists() on the data here because that would
// require fetching the data prematurely which could cause performance issues in extreme cases
return in_array($name, ViewLayerData::META_DATA_NAMES)
|| isset($this->data->$name)
return isset($this->data->$name)
|| ClassInfo::hasMethod($this->data, "get$name")
|| ClassInfo::hasMethod($this->data, $name);
}
@ -215,22 +203,9 @@ class ViewLayerData implements IteratorAggregate, Stringable
$data->objCacheSet($name, $arguments, $value);
}
if ($value === null && in_array($name, ViewLayerData::META_DATA_NAMES)) {
$value = $this->getMetaData($data, $name);
}
return $value;
}
private function getMetaData(object $data, string $name): mixed
{
return match ($name) {
'Me' => $data,
'ClassName' => DBClassName::create()->setValue(get_class($data)),
default => null
};
}
private function callDataMethod(object $data, string $name, array $arguments, bool &$fetchedValue = false): mixed
{
$hasDynamicMethods = method_exists($data, '__call');

View File

@ -11,10 +11,6 @@ use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Injector\InjectorLoader;
use SilverStripe\Core\Kernel;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Core\Environment;
use ReflectionClass;
use SilverStripe\ORM\DB;
use ReflectionObject;
class KernelTest extends SapphireTest
{
@ -85,32 +81,4 @@ class KernelTest extends SapphireTest
$kernel->getConfigLoader()->getManifest();
}
public function testReplicaDatabaseVarsLoaded()
{
// Set environment variables for a fake replica database
Environment::setEnv('SS_DATABASE_SERVER_REPLICA_01', 'the-moon');
Environment::setEnv('SS_DATABASE_USERNAME_REPLICA_01', 'alien');
Environment::setEnv('SS_DATABASE_PASSWORD_REPLICA_01', 'hi_people');
// Get the CoreKernel
/** @var Kernel $kernel */
$kernel = Injector::inst()->get(Kernel::class);
/** @var CoreKernel $coreKernel */
$coreKernel = $kernel->nest();
$this->assertTrue(is_a($coreKernel, CoreKernel::class));
// Boot the database environment variables
$reflector = new ReflectionObject($coreKernel);
$method = $reflector->getMethod('bootDatabaseEnvVars');
$method->setAccessible(true);
$method->invoke($coreKernel);
// Assert DB config was updated
$default = DB::getConfig(DB::CONN_PRIMARY);
$configs = (new ReflectionClass(DB::class))->getStaticPropertyValue('configs');
$this->assertSame([
'type' => $default['type'],
'server' => 'the-moon',
'username' => 'alien',
'password' => 'hi_people',
], $configs['replica_01']);
}
}

View File

@ -3,7 +3,6 @@
namespace SilverStripe\Forms\Tests\GridField;
use LogicException;
use ReflectionMethod;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\SapphireTest;
@ -118,29 +117,6 @@ class GridFieldFilterHeaderTest extends SapphireTest
$this->assertEquals('testfield', $searchSchema->gridfield);
}
/**
* Tests the private method that returns the placeholder for the search field
*/
public function testGetPlaceHolder()
{
$gridField = new GridField('test');
$filterHeader = new GridFieldFilterHeader();
$reflectionGetPlaceHolder = new ReflectionMethod($filterHeader, 'getPlaceHolder');
$reflectionGetPlaceHolder->setAccessible(true);
// No explicit placeholder or model i18n_plural_name method
$this->assertSame('Search "ArrayData"', $reflectionGetPlaceHolder->invoke($filterHeader, new ArrayData()));
// No explicit placeholder, but model has i18n_plural_name method
$model = new DataObject();
$this->assertSame('Search "' . $model->i18n_plural_name() . '"', $reflectionGetPlaceHolder->invoke($filterHeader, $model));
// Explicit placeholder is set, which overrides both of the above cases
$filterHeader->setPlaceHolderText('This is the text');
$this->assertSame('This is the text', $reflectionGetPlaceHolder->invoke($filterHeader, $model));
$this->assertSame('This is the text', $reflectionGetPlaceHolder->invoke($filterHeader, new ArrayData()));
}
public function testHandleActionReset()
{
// Init Grid state with some pre-existing filters

View File

@ -1,260 +0,0 @@
<?php
namespace SilverStripe\ORM\Tests;
use ReflectionClass;
use SilverStripe\Dev\FunctionalTest;
use SilverStripe\ORM\DB;
use SilverStripe\Control\Director;
use SilverStripe\Security\Security;
use SilverStripe\Core\Config\Config;
use SilverStripe\ORM\Tests\DBReplicaTest\TestController;
use SilverStripe\ORM\Tests\DBReplicaTest\TestObject;
use SilverStripe\Security\Group;
use SilverStripe\Security\Member;
use SilverStripe\ORM\DataQuery;
use PHPUnit\Framework\Attributes\DataProvider;
class DBReplicaTest extends FunctionalTest
{
protected static $extra_dataobjects = [
TestObject::class,
];
protected static $fixture_file = 'DBReplicaTest.yml';
protected function setUp(): void
{
parent::setUp();
$this->setupConfigsAndConnections(true);
// Set DB:$mustUsePrimary to true to allow using replicas
// This is disabled by default in SapphireTest::setUpBeforeClass()
// Also reset mustUsePrimary after using mutable sql to create yml fixtures
// and also because by default an ADMIN user is logged in when using fixtures in SapphireTest::setUp()
// and also prevent tests from affecting subsequent tests
(new ReflectionClass(DB::class))->setStaticPropertyValue('mustUsePrimary', false);
}
protected function tearDown(): void
{
$this->setupConfigsAndConnections(false);
// Reset DB:$mustUsePrimary to true which is the default set by SapphireTest::setUpBeforeClass()
(new ReflectionClass(DB::class))->setStaticPropertyValue('mustUsePrimary', true);
parent::tearDown();
}
public function testUsesReplica(): void
{
// Assert uses replica by default
TestObject::get()->count();
$this->assertSame('replica_01', $this->getLastConnectionName());
// Assert uses primary when using withPrimary()
DB::withPrimary(fn() => TestObject::get()->count());
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
// Assert that withPrimary() was only temporary
TestObject::get()->count();
$this->assertSame('replica_01', $this->getLastConnectionName());
// Assert DB::setMustUsePrimary() forces primary from now on
DB::setMustUsePrimary();
TestObject::get()->count();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
}
public function testMutableSql(): void
{
// Assert that using mutable sql in an ORM method with a dataclass uses primary
TestObject::create(['Title' => 'testing'])->write();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
// Assert that now all subsequent queries use primary
TestObject::get()->count();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
}
public function testMutableSqlDbQuery(): void
{
// Assert that using mutable sql in DB::query() uses primary
DB::query('INSERT INTO "DBReplicaTest_TestObject" ("Title") VALUES (\'testing\')');
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
// Assert that now all subsequent queries use primary
TestObject::get()->count();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
}
public function testMutableSqlDbPreparedQuery(): void
{
// Assert that using mutable sql in DB::prepared_query() uses primary
DB::prepared_query('INSERT INTO "DBReplicaTest_TestObject" ("Title") VALUES (?)', ['testing']);
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
// Assert that now all subsequent queries use primary
TestObject::get()->count();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
}
#[DataProvider('provideSetCurrentUser')]
public function testSetCurrentUser(string $firstName, string $expected): void
{
$member = Member::get()->find('FirstName', $firstName);
Security::setCurrentUser($member);
TestObject::get()->count();
$this->assertSame($expected, $this->getLastConnectionName());
}
public function testDataObjectMustUsePrimaryDb(): void
{
// Assert that DataList::getIterator() respect DataObject.must_use_primary_db
foreach (TestObject::get() as $object) {
$object->Title = 'test2';
}
$this->assertSame('replica_01', $this->getLastConnectionName());
foreach (Group::get() as $group) {
$group->Title = 'test2';
}
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName());
// Assert that DataQuery methods without params respect DataObject.must_use_primary_db
$methods = [
'count',
'exists',
'firstRow',
'lastRow'
];
foreach ($methods as $method) {
(new DataQuery(TestObject::class))->$method();
$this->assertSame('replica_01', $this->getLastConnectionName(), "method is $method");
(new DataQuery(Group::class))->$method();
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName(), "method is $method");
}
// Assert that DataQuery methods with a param respect DataObject.must_use_primary_db
$methods = [
'max',
'min',
'avg',
'sum',
'column',
];
foreach ($methods as $method) {
(new DataQuery(TestObject::class))->$method('ID');
$this->assertSame('replica_01', $this->getLastConnectionName(), "method is $method");
(new DataQuery(Group::class))->$method('ID');
$this->assertSame(DB::CONN_PRIMARY, $this->getLastConnectionName(), "method is $method");
}
}
public static function provideSetCurrentUser(): array
{
return [
'non_cms_user' => [
'firstName' => 'random',
'expected' => 'replica_01'
],
'cms_user' => [
'firstName' => 'cmsuser',
'expected' => DB::CONN_PRIMARY
],
];
}
public static function provideRoutes(): array
{
return [
'normal_route' => [
'path' => 'test',
'expected' => 'replica_01'
],
'security_route' => [
'path' => 'Security/login',
'expected' => DB::CONN_PRIMARY
],
'dev_route' => [
'path' => 'dev/tasks',
'expected' => DB::CONN_PRIMARY
],
'dev_in_path_but_not_dev_route' => [
'path' => 'test/dev',
'expected' => 'replica_01'
],
];
}
#[DataProvider('provideRoutes')]
public function testRoutes(string $path, string $expected): void
{
// Create a custom rule to test our controller that should default to using a replica
$rules = Config::inst()->get(Director::class, 'rules');
$rules['test'] = TestController::class;
// Ensure that routes staring with '$' are at the bottom of the assoc array index and don't override
// our new 'test' route
uksort($rules, fn($a, $b) => str_starts_with($a, '$') ? 1 : (str_starts_with($b, '$') ? -1 : 0));
$this->get($path);
$this->assertSame($expected, $this->getLastConnectionName());
}
public static function provideHasReplicaConfig(): array
{
return [
'no_replica' => [
'includeReplica' => false,
'expected' => false
],
'with_replica' => [
'includeReplica' => true,
'expected' => true
],
];
}
#[DataProvider('provideHasReplicaConfig')]
public function testHasReplicaConfig(bool $includeReplica, bool $expected): void
{
$this->assertTrue(DB::hasReplicaConfig());
$primaryConfig = DB::getConfig(DB::CONN_PRIMARY);
$config = [DB::CONN_PRIMARY => $primaryConfig];
if ($includeReplica) {
$config['replica_01'] = $primaryConfig;
}
(new ReflectionClass(DB::class))->setStaticPropertyValue('configs', $config);
$this->assertSame($expected, DB::hasReplicaConfig());
}
public function testHasConfig(): void
{
$this->assertFalse(DB::hasConfig('lorem'));
DB::setConfig(['type' => 'lorem'], 'lorem');
$this->assertTrue(DB::hasConfig('lorem'));
}
public function testGetReplicaConfigKey(): void
{
$this->assertSame('replica_03', DB::getReplicaConfigKey(3));
$this->assertSame('replica_58', DB::getReplicaConfigKey(58));
}
/**
* Using reflection, set DB::configs and DB::connections with a fake a replica connection
* that points to the same connection as the primary connection.
*/
private function setupConfigsAndConnections($includeReplica = true): void
{
$reflector = new ReflectionClass(DB::class);
$primaryConfig = DB::getConfig(DB::CONN_PRIMARY);
$configs = [DB::CONN_PRIMARY => $primaryConfig];
if ($includeReplica) {
$configs['replica_01'] = $primaryConfig;
}
$reflector->setStaticPropertyValue('configs', $configs);
// Create connections
$primaryConnection = DB::get_conn(DB::CONN_PRIMARY);
$connections = [DB::CONN_PRIMARY => $primaryConnection];
if ($includeReplica) {
$connections['replica_01'] = $primaryConnection;
}
$reflector->setStaticPropertyValue('connections', $connections);
}
/**
* Get the last connection name used by the DB class. This shows if a replica was used.
*/
private function getLastConnectionName(): string
{
return (new ReflectionClass(DB::class))->getStaticPropertyValue('lastConnectionName');
}
}

View File

@ -1,18 +0,0 @@
SilverStripe\ORM\Tests\DBReplicaTest\TestObject:
test:
Title: 'test'
SilverStripe\Security\Permission:
test:
Code: 'CMS_ACCESS_Something'
SilverStripe\Security\Group:
test:
Title: 'test'
Permissions:
- =>SilverStripe\Security\Permission.test
SilverStripe\Security\Member:
cmsuser:
FirstName: 'CmsUser'
Groups:
- =>SilverStripe\Security\Group.test
random:
FirstName: 'Random'

View File

@ -1,18 +0,0 @@
<?php
namespace SilverStripe\ORM\Tests\DBReplicaTest;
use SilverStripe\Control\Controller;
use SilverStripe\Dev\TestOnly;
class TestController extends Controller implements TestOnly
{
public function index()
{
// Make a call to the database
TestObject::get()->count();
$response = $this->getResponse();
$response->setBody('DB_REPLICA_TEST_CONTROLLER');
return $response;
}
}

View File

@ -1,16 +0,0 @@
<?php
namespace SilverStripe\ORM\Tests\DBReplicaTest;
use SilverStripe\Control\Controller;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
class TestObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar',
];
private static $table_name = 'DBReplicaTest_TestObject';
}

View File

@ -41,7 +41,7 @@ class MySQLiConnectorTest extends SapphireTest implements TestOnly
{
parent::setUp();
$config = DB::getConfig(DB::CONN_PRIMARY);
$config = DB::getConfig();
if (strtolower(substr($config['type'] ?? '', 0, 5)) !== 'mysql') {
$this->markTestSkipped("The test only relevant for MySQL - but $config[type] is in use");

View File

@ -28,7 +28,6 @@ use PHPUnit\Framework\Attributes\DoesNotPerformAssertions;
use SilverStripe\View\Exception\MissingTemplateException;
use SilverStripe\View\SSTemplateEngine;
use SilverStripe\View\ViewLayerData;
use stdClass;
class SSTemplateEngineTest extends SapphireTest
{
@ -499,23 +498,23 @@ class SSTemplateEngineTest extends SapphireTest
{
return [
[
'arg0:0,arg1:"string",arg2:true',
'arg1:0,arg2:"string",arg3:true',
'$methodWithTypedArguments(0, "string", true).RAW',
],
[
'arg0:false,arg1:"string",arg2:true',
'arg1:false,arg2:"string",arg3:true',
'$methodWithTypedArguments(false, "string", true).RAW',
],
[
'arg0:null,arg1:"string",arg2:true',
'arg1:null,arg2:"string",arg3:true',
'$methodWithTypedArguments(null, "string", true).RAW',
],
[
'arg0:"",arg1:"string",arg2:true',
'arg1:"",arg2:"string",arg3:true',
'$methodWithTypedArguments("", "string", true).RAW',
],
[
'arg0:0,arg1:1,arg2:2',
'arg1:0,arg2:1,arg3:2',
'$methodWithTypedArguments(0, 1, 2).RAW',
],
];
@ -527,62 +526,6 @@ class SSTemplateEngineTest extends SapphireTest
$this->assertEquals($expected, $this->render($template, new TestModelData()));
}
public static function provideEvaluatedArgumentTypes(): array
{
$stdobj = new stdClass();
$stdobj->key = 'value';
$scenarios = [
'null value' => [
'data' => ['Value' => null],
'useOverlay' => true,
'expected' => 'arg0:null',
],
'int value' => [
'data' => ['Value' => 1],
'useOverlay' => true,
'expected' => 'arg0:1',
],
'string value' => [
'data' => ['Value' => '1'],
'useOverlay' => true,
'expected' => 'arg0:"1"',
],
'boolean true' => [
'data' => ['Value' => true],
'useOverlay' => true,
'expected' => 'arg0:true',
],
'boolean false' => [
'data' => ['Value' => false],
'useOverlay' => true,
'expected' => 'arg0:false',
],
'object value' => [
'data' => ['Value' => $stdobj],
'useOverlay' => true,
'expected' => 'arg0:{"key":"value"}',
],
];
foreach ($scenarios as $key => $scenario) {
$scenario['useOverlay'] = false;
$scenarios[$key . ' no overlay'] = $scenario;
}
return $scenarios;
}
#[DataProvider('provideEvaluatedArgumentTypes')]
public function testEvaluatedArgumentTypes(array $data, bool $useOverlay, string $expected): void
{
$template = '$methodWithTypedArguments($Value).RAW';
$model = new TestModelData();
$overlay = $data;
if (!$useOverlay) {
$model = $model->customise($data);
$overlay = [];
}
$this->assertEquals($expected, $this->render($template, $model, $overlay));
}
public function testObjectDotArguments()
{
$this->assertEquals(
@ -2141,11 +2084,11 @@ after'
}
// Test instance behaviors
$this->render($content, cache: false);
$this->assertFileDoesNotExist($cacheFile, 'Cache file was created when caching was off');
$this->render($content, null, false);
$this->assertFalse(file_exists($cacheFile ?? ''), 'Cache file was created when caching was off');
$this->render($content, cache: true);
$this->assertFileExists($cacheFile, 'Cache file wasn\'t created when it was meant to');
$this->render($content, null, true);
$this->assertTrue(file_exists($cacheFile ?? ''), 'Cache file wasn\'t created when it was meant to');
unlink($cacheFile ?? '');
}
@ -2227,14 +2170,14 @@ after'
/**
* Small helper to render templates from strings
*/
private function render(string $templateString, mixed $data = null, array $overlay = [], bool $cache = false): string
private function render(string $templateString, mixed $data = null, bool $cacheTemplate = false): string
{
$engine = new SSTemplateEngine();
if ($data === null) {
$data = new SSTemplateEngineTest\TestFixture();
}
$data = new ViewLayerData($data);
return trim('' . $engine->renderString($templateString, $data, $overlay, $cache));
return trim('' . $engine->renderString($templateString, $data, cache: $cacheTemplate));
}
private function _renderWithSourceFileComments($name, $expected)

View File

@ -29,13 +29,9 @@ class TestModelData extends ModelData implements TestOnly
return "arg1:{$arg1},arg2:{$arg2}";
}
public function methodWithTypedArguments(...$args)
public function methodWithTypedArguments($arg1, $arg2, $arg3)
{
$ret = [];
foreach ($args as $i => $arg) {
$ret[] = "arg$i:" . json_encode($arg);
}
return implode(',', $ret);
return 'arg1:' . json_encode($arg1) . ',arg2:' . json_encode($arg2) . ',arg3:' . json_encode($arg3);
}
public function Type($arg)

View File

@ -23,7 +23,6 @@ use SilverStripe\View\Tests\ViewLayerDataTest\StringableObject;
use SilverStripe\View\Tests\ViewLayerDataTest\TestFixture;
use SilverStripe\View\Tests\ViewLayerDataTest\TestFixtureComplex;
use SilverStripe\View\ViewLayerData;
use stdClass;
use Throwable;
class ViewLayerDataTest extends SapphireTest
@ -728,24 +727,4 @@ class ViewLayerDataTest extends SapphireTest
$viewLayerData->MyField;
$this->assertSame('some value', $data->objCacheGet('MyField'));
}
public function testSpecialNames(): void
{
$data = new stdClass;
$viewLayerData = new ViewLayerData($data);
// Metadata values are available when there's nothing in the actual data
$this->assertTrue(isset($viewLayerData->ClassName));
$this->assertTrue(isset($viewLayerData->Me));
$this->assertSame(stdClass::class, $viewLayerData->getRawDataValue('ClassName')->getValue());
$this->assertSame($data, $viewLayerData->getRawDataValue('Me'));
// Metadata values are lower priority than real values in the actual data
$data->ClassName = 'some other class';
$data->Me = 'something else';
$this->assertTrue(isset($viewLayerData->ClassName));
$this->assertTrue(isset($viewLayerData->Me));
$this->assertSame('some other class', $viewLayerData->getRawDataValue('ClassName'));
$this->assertSame('something else', $viewLayerData->getRawDataValue('Me'));
}
}