getDatabaseServer(); } // Set charset if (empty($parameters['charset']) && ($charset = static::config()->get('connection_charset'))) { $parameters['charset'] = $charset; } // Set collation if (empty($parameters['collation']) && ($collation = static::config()->get('connection_collation'))) { $parameters['collation'] = $collation; } // Notify connector of parameters $this->connector->connect($parameters); // Set sql_mode $this->setSQLMode(static::config()->get('sql_mode')); if (isset($parameters['timezone'])) { $this->selectTimezone($parameters['timezone']); } // SS_Database subclass maintains responsibility for selecting database // once connected in order to correctly handle schema queries about // existence of database, error handling at the correct level, etc if (!empty($parameters['database'])) { $this->selectDatabase($parameters['database'], false, false); } } /** * Sets the SQL mode * * @param string $mode Connection mode */ public function setSQLMode($mode) { if (empty($mode)) { return; } $this->preparedQuery("SET sql_mode = ?", [$mode]); } /** * Sets the system timezone for the database connection * * @param string $timezone */ public function selectTimezone($timezone) { if (empty($timezone)) { return; } $this->preparedQuery("SET SESSION time_zone = ?", [$timezone]); } public function supportsCollations() { return true; } public function supportsTimezoneOverride() { return true; } public function getDatabaseServer() { return "mysql"; } /** * The core search engine, used by this class and its subclasses to do fun stuff. * Searches both SiteTree and File. * * Caution: While the $keywords argument is escaped for safe use in a query context, * you need to ensure that it is also a valid boolean expression when opting into $booleanSearch. * For example, the "asterisk" and "greater than" characters have a special meaning in this context, * and can only be placed in certain parts of the keywords. You will need to preprocess and sanitise * user input accordingly in order to avoid query errors. * * @param array $classesToSearch * @param string $keywords Keywords as a string. * @param int $start * @param int $pageLength * @param string $sortBy * @param string $extraFilter * @param bool $booleanSearch * @param string $alternativeFileFilter * @param bool $invertedMatch * @return PaginatedList * @throws Exception */ public function searchEngine( $classesToSearch, $keywords, $start, $pageLength, $sortBy = "Relevance DESC", $extraFilter = "", $booleanSearch = false, $alternativeFileFilter = "", $invertedMatch = false ) { $pageClass = SiteTree::class; $fileClass = File::class; if (!class_exists($pageClass ?? '')) { throw new Exception('MySQLDatabase->searchEngine() requires "SiteTree" class'); } if (!class_exists($fileClass ?? '')) { throw new Exception('MySQLDatabase->searchEngine() requires "File" class'); } $keywords = $this->escapeString($keywords); $htmlEntityKeywords = htmlentities($keywords ?? '', ENT_NOQUOTES, 'UTF-8'); $extraFilters = [$pageClass => '', $fileClass => '']; $boolean = ''; if ($booleanSearch) { $boolean = "IN BOOLEAN MODE"; } if ($extraFilter) { $extraFilters[$pageClass] = " AND $extraFilter"; if ($alternativeFileFilter) { $extraFilters[$fileClass] = " AND $alternativeFileFilter"; } else { $extraFilters[$fileClass] = $extraFilters[$pageClass]; } } // Always ensure that only pages with ShowInSearch = 1 can be searched $extraFilters[$pageClass] .= " AND ShowInSearch <> 0"; // File.ShowInSearch was added later, keep the database driver backwards compatible // by checking for its existence first $fileTable = DataObject::getSchema()->tableName($fileClass); $fields = $this->getSchemaManager()->fieldList($fileTable); if (array_key_exists('ShowInSearch', $fields ?? [])) { $extraFilters[$fileClass] .= " AND ShowInSearch <> 0"; } $limit = (int)$start . ", " . (int)$pageLength; $notMatch = $invertedMatch ? "NOT " : ""; if ($keywords) { $match[$pageClass] = " MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$keywords' $boolean) + MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityKeywords' $boolean) "; $fileClassSQL = Convert::raw2sql($fileClass); $match[$fileClass] = "MATCH (Name, Title) AGAINST ('$keywords' $boolean) AND ClassName = '$fileClassSQL'"; // We make the relevance search by converting a boolean mode search into a normal one $booleanChars = ['*', '+', '@', '-', '(', ')', '<', '>']; $relevanceKeywords = str_replace($booleanChars ?? '', '', $keywords ?? ''); $htmlEntityRelevanceKeywords = str_replace($booleanChars ?? '', '', $htmlEntityKeywords ?? ''); $relevance[$pageClass] = "MATCH (Title, MenuTitle, Content, MetaDescription) " . "AGAINST ('$relevanceKeywords') " . "+ MATCH (Title, MenuTitle, Content, MetaDescription) AGAINST ('$htmlEntityRelevanceKeywords')"; $relevance[$fileClass] = "MATCH (Name, Title) AGAINST ('$relevanceKeywords')"; } else { $relevance[$pageClass] = $relevance[$fileClass] = 1; $match[$pageClass] = $match[$fileClass] = "1 = 1"; } // Generate initial DataLists and base table names $lists = []; $sqlTables = [$pageClass => '', $fileClass => '']; foreach ($classesToSearch as $class) { $lists[$class] = DataList::create($class)->where($notMatch . $match[$class] . $extraFilters[$class]); $sqlTables[$class] = '"' . DataObject::getSchema()->tableName($class) . '"'; } $charset = static::config()->get('charset'); // Make column selection lists $select = [ $pageClass => [ "ClassName", "{$sqlTables[$pageClass]}.\"ID\"", "ParentID", "Title", "MenuTitle", "URLSegment", "Content", "LastEdited", "Created", "Name" => "_{$charset}''", "Relevance" => $relevance[$pageClass], "CanViewType" ], $fileClass => [ "ClassName", "{$sqlTables[$fileClass]}.\"ID\"", "ParentID", "Title", "MenuTitle" => "_{$charset}''", "URLSegment" => "_{$charset}''", "Content" => "_{$charset}''", "LastEdited", "Created", "Name", "Relevance" => $relevance[$fileClass], "CanViewType" => "NULL" ], ]; // Process and combine queries $querySQLs = []; $queryParameters = []; $totalCount = 0; foreach ($lists as $class => $list) { /** @var SQLSelect $query */ $query = $list->dataQuery()->query(); // There's no need to do all that joining $query->setFrom($sqlTables[$class]); $query->setSelect($select[$class]); $query->setOrderBy([]); $querySQLs[] = $query->sql($parameters); $queryParameters = array_merge($queryParameters, $parameters); $totalCount += $query->unlimitedRowCount(); } $fullQuery = implode(" UNION ", $querySQLs) . " ORDER BY $sortBy LIMIT $limit"; // Get records $records = $this->preparedQuery($fullQuery, $queryParameters); $objects = []; foreach ($records as $record) { $objects[] = new $record['ClassName']($record, DataObject::CREATE_HYDRATED); } $list = new PaginatedList(new ArrayList($objects)); $list->setPageStart($start); $list->setPageLength($pageLength); $list->setTotalItems($totalCount); // The list has already been limited by the query above $list->setLimitItems(false); return $list; } /** * Returns the TransactionManager to handle transactions for this database. * * @return TransactionManager */ protected function getTransactionManager() { if (!$this->transactionManager) { // PDOConnector providers this if ($this->connector instanceof TransactionManager) { $this->transactionManager = new NestedTransactionManager($this->connector); // Direct database access does not } else { $this->transactionManager = new NestedTransactionManager(new MySQLTransactionManager($this)); } } return $this->transactionManager; } public function supportsTransactions() { return true; } public function supportsSavepoints() { return $this->getTransactionManager()->supportsSavepoints(); } public function transactionStart($transactionMode = false, $sessionCharacteristics = false) { $this->getTransactionManager()->transactionStart($transactionMode, $sessionCharacteristics); } public function transactionSavepoint($savepoint) { $this->getTransactionManager()->transactionSavepoint($savepoint); } public function transactionRollback($savepoint = false) { return $this->getTransactionManager()->transactionRollback($savepoint); } public function transactionDepth() { return $this->getTransactionManager()->transactionDepth(); } public function transactionEnd($chain = false) { $result = $this->getTransactionManager()->transactionEnd(); if ($chain) { Deprecation::notice('4.4', '$chain argument is deprecated'); return $this->getTransactionManager()->transactionStart(); } return $result; } /** * In error condition, set transactionNesting to zero */ protected function resetTransactionNesting() { // Check whether to use a connector's built-in transaction methods if ($this->connector instanceof TransactionalDBConnector) { if ($this->transactionNesting > 0) { $this->connector->transactionRollback(); } } $this->transactionNesting = 0; } public function query($sql, $errorLevel = E_USER_ERROR) { $this->inspectQuery($sql); return parent::query($sql, $errorLevel); } public function preparedQuery($sql, $parameters, $errorLevel = E_USER_ERROR) { $this->inspectQuery($sql); return parent::preparedQuery($sql, $parameters, $errorLevel); } /** * Inspect a SQL query prior to execution * * @param string $sql */ protected function inspectQuery($sql) { // Any DDL discards transactions. // See https://dev.mysql.com/doc/internals/en/transactions-notes-on-ddl-and-normal-transaction.html // on why we need to be over-eager $isDDL = $this->getConnector()->isQueryDDL($sql); if ($isDDL) { $this->resetTransactionNesting(); } } public function comparisonClause( $field, $value, $exact = false, $negate = false, $caseSensitive = null, $parameterised = false ) { if ($exact && $caseSensitive === null) { $comp = ($negate) ? '!=' : '='; } else { $comp = ($caseSensitive) ? 'LIKE BINARY' : 'LIKE'; if ($negate) { $comp = 'NOT ' . $comp; } } if ($parameterised) { return sprintf("%s %s ?", $field, $comp); } else { return sprintf("%s %s '%s'", $field, $comp, $value); } } public function formattedDatetimeClause($date, $format) { preg_match_all('/%(.)/', $format ?? '', $matches); foreach ($matches[1] as $match) { if (array_search($match, ['Y', 'm', 'd', 'H', 'i', 's', 'U']) === false) { user_error('formattedDatetimeClause(): unsupported format character %' . $match, E_USER_WARNING); } } if (preg_match('/^now$/i', $date ?? '')) { $date = "NOW()"; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date ?? '')) { $date = "'$date'"; } if ($format == '%U') { return "UNIX_TIMESTAMP($date)"; } return "DATE_FORMAT($date, '$format')"; } public function datetimeIntervalClause($date, $interval) { $interval = preg_replace('/(year|month|day|hour|minute|second)s/i', '$1', $interval ?? ''); if (preg_match('/^now$/i', $date ?? '')) { $date = "NOW()"; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date ?? '')) { $date = "'$date'"; } return "$date + INTERVAL $interval"; } public function datetimeDifferenceClause($date1, $date2) { // First date format if (preg_match('/^now$/i', $date1 ?? '')) { $date1 = "NOW()"; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date1 ?? '')) { $date1 = "'$date1'"; } // Second date format if (preg_match('/^now$/i', $date2 ?? '')) { $date2 = "NOW()"; } elseif (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/i', $date2 ?? '')) { $date2 = "'$date2'"; } return "UNIX_TIMESTAMP($date1) - UNIX_TIMESTAMP($date2)"; } public function supportsLocks() { return true; } public function canLock($name) { $id = $this->getLockIdentifier($name); return (bool) $this->query(sprintf("SELECT IS_FREE_LOCK('%s')", $id))->value(); } public function getLock($name, $timeout = 5) { $id = $this->getLockIdentifier($name); // MySQL 5.7.4 and below auto-releases existing locks on subsequent GET_LOCK() calls. // MySQL 5.7.5 and newer allow multiple locks per sessions even with the same name. // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock return (bool) $this->query(sprintf("SELECT GET_LOCK('%s', %d)", $id, $timeout))->value(); } public function releaseLock($name) { $id = $this->getLockIdentifier($name); return (bool) $this->query(sprintf("SELECT RELEASE_LOCK('%s')", $id))->value(); } protected function getLockIdentifier($name) { // Prefix with database name $dbName = $this->connector->getSelectedDatabase() ; return $this->escapeString("{$dbName}_{$name}"); } public function now() { // MySQL uses NOW() to return the current date/time. return 'NOW()'; } public function random() { return 'RAND()'; } /** * Clear all data in a given table * * @param string $table Name of table */ public function clearTable($table) { $this->query("DELETE FROM \"$table\""); // Check if resetting the auto-increment is needed $autoIncrement = $this->preparedQuery( 'SELECT "AUTO_INCREMENT" FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?', [ $this->getSelectedDatabase(), $table] )->value(); if ($autoIncrement > 1) { $this->query("ALTER TABLE \"$table\" AUTO_INCREMENT = 1"); } } }