diff --git a/core/modules/locale/lib/Drupal/locale/LocaleLookup.php b/core/modules/locale/lib/Drupal/locale/LocaleLookup.php index 897eaa1..3b295d2 100644 --- a/core/modules/locale/lib/Drupal/locale/LocaleLookup.php +++ b/core/modules/locale/lib/Drupal/locale/LocaleLookup.php @@ -8,6 +8,8 @@ namespace Drupal\locale; use Drupal\Core\Utility\CacheArray; +use Drupal\locale\LocaleSource; +use Drupal\locale\LocaleTranslation; /** * Extends CacheArray to allow for dynamic building of the locale cache. @@ -27,11 +29,19 @@ class LocaleLookup extends CacheArray { protected $context; /** + * The locale storage + * + * @var Drupal\locale\StringStorageInterface + */ + protected $stringStorage; + + /** * Constructs a LocaleCache object. */ - public function __construct($langcode, $context) { + public function __construct($langcode, $context, $stringStorage) { $this->langcode = $langcode; $this->context = (string) $context; + $this->stringStorage = $stringStorage; // Add the current user's role IDs to the cache key, this ensures that, for // example, strings for admin menu items and settings forms are not cached @@ -44,36 +54,26 @@ class LocaleLookup extends CacheArray { * Overrides DrupalCacheArray::resolveCacheMiss(). */ protected function resolveCacheMiss($offset) { - $translation = db_query("SELECT s.lid, t.translation, s.version FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.source = :source AND s.context = :context", array( - ':language' => $this->langcode, - ':source' => $offset, - ':context' => $this->context, - ))->fetchObject(); + $translation = $this->stringStorage->findTranslation(array( + 'language' => $this->langcode, + 'source' => $offset, + 'context' => $this->context + ), array('version', 'translation')); + if ($translation) { - if ($translation->version != VERSION) { - // This is the first use of this string under current Drupal version. - // Update the {locales_source} table to indicate the string is current. - db_update('locales_source') - ->fields(array('version' => VERSION)) - ->condition('lid', $translation->lid) - ->execute(); - } + $this->stringStorage->checkVersion($translation, VERSION); $value = !empty($translation->translation) ? $translation->translation : TRUE; } else { // We don't have the source string, update the {locales_source} table to // indicate the string is not translated. - db_merge('locales_source') - ->insertFields(array( - 'location' => request_uri(), - 'version' => VERSION, - )) - ->key(array( - 'source' => $offset, - 'context' => $this->context, - )) - ->execute(); - $value = TRUE; + $this->stringStorage->createString(array( + 'source' => $offset, + 'context' => $this->context, + 'location' => request_uri(), + 'version' => VERSION + ))->save(); + $value = TRUE; } $this->storage[$offset] = $value; // Disabling the usage of string caching allows a module to watch for diff --git a/core/modules/locale/lib/Drupal/locale/LocaleSource.php b/core/modules/locale/lib/Drupal/locale/LocaleSource.php new file mode 100644 index 0000000..3dae16a --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/LocaleSource.php @@ -0,0 +1,66 @@ +source; + } + + /** + * Implements Drupal\locale\LocaleString::setString(). + */ + public function setString($string) { + $this->source = $string; + return $this; + } + + /** + * Implements Drupal\locale\LocaleString::isNew(). + */ + public function isNew() { + return empty($this->lid); + } + + /** + * Implements Drupal\locale\StringInterface::setDefaults(). + */ + public function setDefaults() { + $this->setValues(array( + 'version' => 'none', + 'source' => '', + 'context' => '', + 'location' => '' + ), FALSE); + return $this; + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/LocaleTranslation.php b/core/modules/locale/lib/Drupal/locale/LocaleTranslation.php new file mode 100644 index 0000000..9633d32 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/LocaleTranslation.php @@ -0,0 +1,135 @@ +is_new)) { + // We mark the string as not new if it has any of the translation fields. + // This will work when loading from database, otherwise the storage + // controller that creates the string object must handle it. + $this->is_new = !isset($this->language) && !isset($this->translation) && !isset($this->customized); + } + } + + /** + * Sets the string as customized / not customized. + * + * @param bool $customized + * (optional) Whether the string is customized or not. Defaults to TRUE. + * + * @return Drupal\locale\LocaleTranslation + * The called object. + */ + public function setCustomized($customized = TRUE) { + $this->customized = $customized ? LOCALE_CUSTOMIZED : LOCALE_NOT_CUSTOMIZED; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::isSource(). + */ + public function isSource() { + return FALSE; + } + + /** + * Implements Drupal\locale\StringInterface::isTranslation(). + */ + public function isTranslation() { + return TRUE; + } + + /** + * Implements Drupal\locale\StringInterface::getString(). + */ + public function getString() { + return $this->translation; + } + + /** + * Implements Drupal\locale\StringInterface::setString(). + */ + public function setString($string) { + $this->translation = $string; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::isNew(). + */ + public function isNew() { + return $this->is_new; + } + + /** + * Implements Drupal\locale\StringInterface::save(). + */ + public function save() { + parent::save(); + $this->is_new = FALSE; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::delete(). + */ + public function delete() { + parent::delete(); + $this->is_new = TRUE; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::setDefaults(). + */ + public function setDefaults() { + $this->setValues(array( + 'language' => '', + 'translation' => '', + 'customized' => LOCALE_NOT_CUSTOMIZED + ), FALSE); + return $this; + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php b/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php index c0dfc2f..117543e 100644 --- a/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php +++ b/core/modules/locale/lib/Drupal/locale/PoDatabaseReader.php @@ -10,6 +10,8 @@ namespace Drupal\locale; use Drupal\Component\Gettext\PoHeader; use Drupal\Component\Gettext\PoItem; use Drupal\Component\Gettext\PoReaderInterface; +use Drupal\locale\LocaleTranslation; +use PDO; /** * Gettext PO reader working with the locale module database. @@ -106,71 +108,66 @@ class PoDatabaseReader implements PoReaderInterface { /** * Builds and executes a database query based on options set earlier. */ - private function buildQuery() { + private function loadStrings() { $langcode = $this->_langcode; $options = $this->_options; + $conditions = $query_options = array(); if (array_sum($options) == 0) { // If user asked to not include anything in the translation files, // that would not make sense, so just fall back on providing a template. $langcode = NULL; + // Force option to get both translated and untranslated strings. + $options['not_translated'] = TRUE; } - // Build and execute query to collect source strings and translations. - $query = db_select('locales_source', 's'); if (!empty($langcode)) { - if ($options['not_translated']) { - // Left join to keep untranslated strings in. - $query->leftJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $langcode)); - } - else { - // Inner join to filter for only translations. - $query->innerJoin('locales_target', 't', 's.lid = t.lid AND t.language = :language', array(':language' => $langcode)); - } + $conditions['language'] = $langcode; + // Translate some options into field conditions. if ($options['customized']) { if (!$options['not_customized']) { // Filter for customized strings only. - $query->condition('t.customized', LOCALE_CUSTOMIZED); + $conditions['customized'] = LOCALE_CUSTOMIZED; } // Else no filtering needed in this case. } else { if ($options['not_customized']) { // Filter for non-customized strings only. - $query->condition('t.customized', LOCALE_NOT_CUSTOMIZED); + $conditions['customized'] = LOCALE_NOT_CUSTOMIZED; } else { // Filter for strings without translation. - $query->isNull('t.translation'); + $query_options['translated'] = FALSE; } } - $query->fields('t', array('translation')); + // Load untranslated strings or not depending on 'not_translated' option. + $query_options['untranslated'] = $options['not_translated']; + + return locale_storage()->getTranslations($conditions, $query_options); } else { - $query->leftJoin('locales_target', 't', 's.lid = t.lid'); + // If no language, we don't need any of the target fields. + return locale_storage()->getStrings($conditions, $query_options); } - $query->fields('s', array('lid', 'source', 'context', 'location')); - - $this->_result = $query->execute(); } /** * Get the database result resource for the given language and options. */ - private function getResult() { + private function readString() { if (!isset($this->_result)) { - $this->buildQuery(); + $this->_result = $this->loadStrings(); } - return $this->_result; + return array_shift($this->_result); } /** * Implements Drupal\Component\Gettext\PoReaderInterface::readItem(). */ function readItem() { - $result = $this->getResult(); - $values = $result->fetchAssoc(); - if ($values) { + if ($string = $this->readString()) { + $values = (array)$string; $poItem = new PoItem(); $poItem->setFromArray($values); return $poItem; diff --git a/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php b/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php index 33f05d9..025e2f4 100644 --- a/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php +++ b/core/modules/locale/lib/Drupal/locale/PoDatabaseWriter.php @@ -11,6 +11,8 @@ use Drupal\Component\Gettext\PoHeader; use Drupal\Component\Gettext\PoItem; use Drupal\Component\Gettext\PoReaderInterface; use Drupal\Component\Gettext\PoWriterInterface; +use Drupal\locale\LocaleSource; +use Drupal\locale\LocaleTranslation; /** * Gettext PO writer working with the locale module database. @@ -226,12 +228,11 @@ class PoDatabaseWriter implements PoWriterInterface { // Look up the source string and any existing translation. - $string = db_query("SELECT s.lid, t.customized FROM {locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.source = :source AND s.context = :context", array( - ':source' => $source, - ':context' => $context, - ':language' => $this->_langcode, - )) - ->fetchObject(); + $string = locale_storage()->findTranslation(array( + 'language' => $this->_langcode, + 'source' => $source, + 'context' => $context + ), array('customized')); if (!empty($translation)) { // Skip this string unless it passes a check for dangerous code. @@ -240,64 +241,40 @@ class PoDatabaseWriter implements PoWriterInterface { $this->_report['skips']++; return 0; } - elseif (isset($string->lid)) { - if (!isset($string->customized)) { + elseif ($string) { + $string->setString($translation); + if ($string->isNew()) { // No translation in this language. - db_insert('locales_target') - ->fields(array( - 'lid' => $string->lid, - 'language' => $this->_langcode, - 'translation' => $translation, - 'customized' => $customized, - )) - ->execute(); - + $string->customized = $customized; + $string->save(); $this->_report['additions']++; } elseif ($overwrite_options[$string->customized ? 'customized' : 'not_customized']) { // Translation exists, only overwrite if instructed. - db_update('locales_target') - ->fields(array( - 'translation' => $translation, - 'customized' => $customized, - )) - ->condition('language', $this->_langcode) - ->condition('lid', $string->lid) - ->execute(); - + $string->customized = $customized; + $string->save(); $this->_report['updates']++; } return $string->lid; } else { // No such source string in the database yet. - $lid = db_insert('locales_source') - ->fields(array( - 'source' => $source, - 'context' => $context, - )) - ->execute(); - - db_insert('locales_target') - ->fields(array( - 'lid' => $lid, - 'language' => $this->_langcode, - 'translation' => $translation, - 'customized' => $customized, - )) - ->execute(); + $string = locale_storage()->createString(array('source' => $source, 'context' => $context)) + ->save(); + $target = locale_storage()->createTranslation(array( + 'lid' => $string->getId(), + 'language' => $this->_langcode, + 'translation' => $translation, + 'customized' => $customized, + ))->save(); $this->_report['additions']++; - return $lid; + return $string->lid; } } - elseif (isset($string->lid) && isset($string->customized) && $overwrite_options[$string->customized ? 'customized' : 'not_customized']) { + elseif ($string && !$string->isNew() && $overwrite_options[$string->customized ? 'customized' : 'not_customized']) { // Empty translation, remove existing if instructed. - db_delete('locales_target') - ->condition('language', $this->_langcode) - ->condition('lid', $string->lid) - ->execute(); - + $string->delete(); $this->_report['deletes']++; return $string->lid; } diff --git a/core/modules/locale/lib/Drupal/locale/StringBase.php b/core/modules/locale/lib/Drupal/locale/StringBase.php new file mode 100644 index 0000000..e76d955 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/StringBase.php @@ -0,0 +1,161 @@ +setValues((array)$values); + } + + /** + * Implements Drupal\locale\StringInterface::getId(). + */ + public function getId() { + return isset($this->lid) ? $this->lid : NULL; + } + + /** + * Implements Drupal\locale\StringInterface::setId(). + */ + public function setId($lid) { + $this->lid = $lid; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::getVersion(). + */ + public function getVersion() { + return isset($this->version) ? $this->version : NULL; + } + + /** + * Implements Drupal\locale\StringInterface::setVersion(). + */ + public function setVersion($version) { + $this->version = $version; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::setStorage(). + */ + public function setStorage($storage) { + $this->storage = $storage; + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::setValues(). + */ + public function setValues(array $values, $override = TRUE) { + foreach ($values as $key => $value) { + if (property_exists($this, $key) && ($override || !isset($this->$key))) { + $this->$key = $value; + } + } + return $this; + } + + /** + * Implements Drupal\locale\StringInterface::getValues(). + */ + public function getValues(array $fields) { + $values = array(); + foreach ($fields as $field) { + if (isset($this->$field)) { + $values[$field] = $this->$field; + } + } + return $values; + } + + /** + * Implements Drupal\locale\StringInterface::getPlurals(). + */ + public function getPlurals() { + return explode(LOCALE_PLURAL_DELIMITER, $this->getString()); + } + + /** + * Implements Drupal\locale\StringInterface::setPlurals(). + */ + public function setPlurals($plurals) { + $this->setString(implode(LOCALE_PLURAL_DELIMITER, $plurals)); + return $this; + } + + /** + * Implements Drupal\locale\LocaleString::save(). + */ + public function save() { + $this->storage->save($this); + return $this; + } + + /** + * Implements Drupal\locale\LocaleString::delete(). + */ + public function delete() { + if (!$this->isNew()) { + $this->storage->delete($this); + unset($this->lid); + } + return $this; + } +} diff --git a/core/modules/locale/lib/Drupal/locale/StringDatabaseStorage.php b/core/modules/locale/lib/Drupal/locale/StringDatabaseStorage.php new file mode 100644 index 0000000..f1efb6f --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/StringDatabaseStorage.php @@ -0,0 +1,482 @@ +connection = $connection; + $this->options = $options; + } + + /** + * Get select query. + */ + protected function selectQuery($table, $alias = NULL) { + return $this->connection->select($table, $alias, $this->options); + } + + /** + * Delete record from database. + * + * @param string|array $table + * Table name or array of table names. + * @param array $keys + * Array with object keys indexed by field name. + */ + Protected function deleteRecord($table, $keys) { + $tables = is_array($table) ? $table : array($table); + foreach ($tables as $table) { + $query = $this->connection->delete($table, $this->options); + foreach ($keys as $field => $value) { + $query->condition($field, $value); + } + $query->execute(); + } + } + + /** + * Implements Drupal\locale\StringStorageInterface::findSource(). + */ + public function findString(array $conditions, array $fields = array()) { + return $this->findObject('Drupal\locale\LocaleSource', array( + 'conditions' => $conditions, + 'fields' => $fields ? $fields : array('*'), + 'from' => '{locales_source} s', + )); + } + + /** + * Implements Drupal\locale\StringStorageInterface::findTranslation(). + */ + public function findTranslation(array $conditions, array $fields = array()) { + $query = array(); + $query['from'] = '{locales_source} s LEFT JOIN {locales_target} t ON s.lid = t.lid'; + if (isset($conditions['language'])) { + $query['from'] .= ' AND t.language = :langcode'; + $query['args'][':langcode'] = $conditions['language']; + $query['values']['language'] = $conditions['language']; + unset($conditions['language']); + } + $query['conditions'] = $conditions; + $query['fields'] = $fields ? $fields : array('*', 'translation', 'language', 'customized'); + return $this->findObject('Drupal\locale\LocaleTranslation', $query); + } + + /** + * Build and run a fast select query. + */ + protected function findObject($class, $query) { + $query += array('conditions' => array(), 'fields' => array(), 'args' => array(), 'values' => array()); + foreach ($query['conditions'] as $field => $value) { + $query['where'][] = $this->getFieldTableAlias($field) . '.' . $field . ' = :' . $field; + $query['args'][':' . $field] = $value; + } + foreach ($query['fields'] as $index => $field) { + $query['fields'][$index] = $this->getFieldTableAlias($field) . '.' . $field; + } + $sql = 'SELECT ' . implode(', ', $query['fields']) . ' FROM ' . $query['from']; + if (isset($query['join'])) { + $sql .= ' ' . $query['join']; + } + if (isset($query['where'])) { + $sql .= ' WHERE ' . implode(' AND ', $query['where']); + } + $string = $this->connection->query($sql, $query['args'], $this->options)->fetchObject($class); + if ($string) { + // Since we don't load all values, fill from conditions and set storage. + $string->setValues($query['values'] + $query['conditions'] + array('storage' => $this), FALSE); + } + return $string; + } + + /** + * Implements Drupal\locale\StringStorageInterface::countStrings(). + */ + public function countStrings() { + return $this->selectQuery('locales_source') + ->countQuery() + ->execute() + ->fetchField(); + } + + /** + * Implements Drupal\locale\StringStorageInterface::countTranslations(). + */ + public function countTranslations() { + $query = $this->selectQuery('locales_target'); + $query->addExpression('language, COUNT(lid)', 'translations'); + $query->groupBy('language'); + return $query->execute()->fetchAllKeyed(); + } + + /** + * Implements Drupal\locale\StringStorageInterface::checkVersion(). + */ + public function checkVersion($string, $version) { + if ($string->getId() && $string->getVersion() != $version) { + $string->setVersion($version); + $this->connection->update('locales_source', $this->options) + ->condition('lid', $string->getId()) + ->fields(array('version' => $version)) + ->execute(); + } + } + + /** + * Implements Drupal\locale\StringStorageInterface::save(). + */ + public function save($string) { + if ($string->isSource()) { + $table = 'locales_source'; + } + elseif ($string->isTranslation()) { + $table = 'locales_target'; + } + if ($table) { + if ($string->isNew()) { + $string->setDefaults(); + $fields = $this->getStringFields($string, $table); + $result = $this->connection->insert($table, $this->options) + ->fields($fields) + ->execute(); + if ($string->isSource() && $result) { + // Only for source strings, we set the locale identifier. + $string->setId($result); + } + } + else { + $fields = $this->getStringFields($string, $table); + if ($keys = $this->getStringKeys($string, $table)) { + $values = array_diff_key($fields, $keys); + $this->connection->merge($table, $this->options) + ->key($keys) + ->fields($values) + ->execute(); + } + } + } + // The operation failed. + return $this; + } + + /** + * Implements Drupal\locale\StringStorageInterface::delete(). + */ + public function delete($string) { + if ($string->isSource()) { + $keys = array('lid'); + $tables = array('locales_source', 'locales_target'); + $this->deleteRecord('locales_source', $keys); + } + elseif ($string->isTranslation()) { + $keys = array('lid', 'language'); + $tables = array('locales_target'); + } + if (!empty($keys)) { + $values = $string->getValues($keys); + if (count($keys) == count($values)) { + $this->deleteRecord($tables, $keys); + } + else { + // @todo Not enough keys, we should trow an exception here. + } + } + return $this; + } + + /** + * Implements Drupal\locale\StringStorageInterface::deleteLanguage(). + */ + public function deleteLanguage($langcode) { + $this->deleteRecord('locales_target', array('language' => $langcode)); + } + + /** + * Loads multiple string source objects. + * + * In order to produce a query as fast as possible we must pass the exact + * fields we need to load only that ones. + * + * @param array $conditions + * (optional) Array with simple field conditions. + * @param array $options + * (optional) An associative array of additional options that may contain + * any of the elements used by the sgetQuery() method. Defaults to an + * empty array. + * + * @return array + * Array of Drupal\locale\LocaleSource objects matching the conditions. + */ + public function getStrings(array $conditions = array(), array $options = array(), array $fields = array()) { + $options += array( + 'class' => 'Drupal\locale\LocaleSource' + ); + return $this->loadMultiple($conditions, $options); + } + + /** + * Implements Drupal\locale\StringStorageInterface::loadTranslations(). + */ + public function getTranslations(array $conditions = array(), array $options = array(), array $fields = array()) { + $options += array( + 'untranslated' => FALSE, + 'class' => 'Drupal\locale\LocaleTranslation', + ); + return $this->loadMultiple($conditions, $options, $fields); + } + + /** + * Implements Drupal\locale\StringStorageInterface::createString(). + */ + public function createString($values = array()) { + return new LocaleSource($values + array('storage' => $this)); + } + + /** + * Implements Drupal\locale\StringStorageInterface::createTranslation(). + */ + public function createTranslation($values = array()) { + return new LocaleTranslation($values + array('storage' => $this, 'is_new' => TRUE)); + } + + /** + * Implements Drupal\locale\StringStorageInterface::loadMultiple(). + */ + protected function loadMultiple(array $conditions, array $options, array $fields = array()) { + $result = $this->getQuery($conditions, $options, $fields)->execute(); + $result->setFetchMode(PDO::FETCH_CLASS, $options['class']); + $strings = $result->fetchAll(); + foreach ($strings as $string) { + $string->setStorage($this); + } + return $strings; + } + + /** + * Builds strings query with multiple conditions and fields. + * + * The query uses both 'locales_source' and 'locales_target' tables. + * Note that by default, as we are selecting both translated and untranslated + * strings target field's conditions will be modified to match NULL rows too. + * + * @param array $conditions + * An associative array with field => value conditions that may include + * NULL values. If a language condition is included it will be used for + * joining the 'locales_target' table. + * @param array $options + * An associative array of additional options. It may contain any of the + * options used by Drupal\locale\StringStorageInterface::getStrings(): + * @param array $fields + * (optional) Fields to load. Defaults to all fields from involved tables. + * + * @return SelectQuery + * Query object with all the tables, fields and conditions. + */ + protected function getQuery(array $conditions, array $options, array $fields = array()) { + // Add default options and see which kind of join we need. + $options += array('translated' => TRUE, 'untranslated' => TRUE); + + // Group conditions and fields by their table alias. + $table_conditions = $table_fields = array(); + foreach ($conditions as $field => $value) { + $table_conditions[$this->getFieldTableAlias($field)][$field] = $value; + } + // Group fields by their table alias eliminating duplicates. + if ($fields) { + foreach ($fields as $name) { + $table_fields[$this->getFieldTableAlias($name)][$name] = $name; + } + } + else { + // We don't have fields option, start with all source fields, + $table_fields['s'] = array(); + } + + // Translate some options into conditions. + if (!$options['translated']) { + // Select only untranslated strings. + $join = 'leftJoin'; + $table_conditions['t']['translation'] = NULL; + } + elseif (!$options['untranslated']) { + // Select only translated strings. + $join = 'innerJoin'; + } + elseif (isset($table_conditions['t']) || isset($table_fields['t'])) { + // If we have conditions or fields in target table we need to join it. + // Outer join because we are loading translated and untranslated. + $join = 'leftJoin'; + } + else { + $join = FALSE; + } + + // Start building the query with source table and fields. + $query = $this->selectQuery('locales_source', 's'); + if (isset($table_fields['s'])) { + $query->fields('s', $table_fields['s']); + } + // We may need to join the target table too. + if ($join) { + if (isset($conditions['language'])) { + // If we've got a language condition, we use it for the join. + $query->$join('locales_target', 't', "t.lid = s.lid AND t.language = :langcode", array(':langcode' => $conditions['language'])); + unset($table_conditions['t']['language']); + } + else { + // Since we don't have a language, join with locale id only. + $query->$join('locales_target', 't', "t.lid = s.lid"); + } + // Since we have joined the table, add all fields if we don't have any, + // but not 'lid' that is in the sources table. + if (!$fields) { + $table_fields['t'] = array('language', 'translation', 'customized'); + } + if (isset($table_fields['t'])) { + $query->fields('t', $table_fields['t']); + } + } + + // Add conditions for all tables, handling NULL conditions too. + foreach ($table_conditions as $table_alias => $alias_conditions) { + foreach ($alias_conditions as $field => $value) { + $field_alias = $table_alias . '.' . $field; + if (is_null($value)) { + $query->isNull($field_alias); + } + elseif ($table_alias == 't' && $join === 'leftJoin') { + // Conditions for target fields when doing an outer join only make + // sense if we add also OR field IS NULL. + $query->condition(db_or() + ->condition($field_alias, $value) + ->isNull($field_alias) + ); + } + else { + $query->condition($field_alias, $value); + } + } + } + + // Process other options, string filter, query limit, etc... + if (!empty($options['filters'])) { + if (count($options['filters']) > 1) { + $filter = db_or(); + $query->condition($filter); + } + else { + // If we have a single filter, just add it to the query. + $filter = $query; + } + foreach ($options['filters'] as $field => $string) { + $filter->condition($this->getFieldTableAlias($field) . '.' . $field, '%' . db_like($string) . '%', 'LIKE'); + } + } + + if (!empty($options['pager limit'])) { + $query = $query->extend('Drupal\Core\Database\Query\PagerSelectExtender')->limit($options['pager limit']); + } + + return $query; + } + + /** + * Gets table alias for field. + * + * @param string $field + * Field name to find the table alias for. + * + * @return string + * Either 's' or 't' depending on whether the field belongs to source or + * target table. + */ + protected static function getFieldTableAlias($field) { + return in_array($field, array('language', 'translation', 'customized')) ? 't' : 's'; + } + + /** + * Gets table name for storing string object. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * + * @return string + * The table name. + */ + protected static function getStringTable($string) { + if ($string->isSource()) { + return 'locales_source'; + } + elseif ($string->isTranslation()) { + return 'locales_target'; + } + } + + /** + * Gets string keys that are in a database table from the table schema. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * @param string $table + * The table name. + */ + protected static function getStringKeys($string, $table) { + if ($schema = drupal_get_schema($table)) { + $keys = $schema['primary key']; + return $string->getValues($keys); + } + } + + /** + * Gets string fields that are in a database table from the table schema. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * @param string $table + * The table name. + */ + protected static function getStringFields($string, $table) { + if ($schema = drupal_get_schema($table)) { + $fields = array_keys($schema['fields']); + return $string->getValues($fields); + } + } + +} diff --git a/core/modules/locale/lib/Drupal/locale/StringInterface.php b/core/modules/locale/lib/Drupal/locale/StringInterface.php new file mode 100644 index 0000000..921e632 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/StringInterface.php @@ -0,0 +1,184 @@ + Class name to create objects of this class. + * - 'translated' (defaults to TRUE): Whether to include translated + * strings. + * - 'untranslated' (defaults to TRUE): Whether to include untranslated + * strings. + * - 'filters': Array of string filters indexed by field name. + * - 'pager limit': Use pager and set this limit value. + * @param array $fields + * (optional) List of field names to retrieve. Defaults to all fields. + * + * @return array + * Array of Drupal\locale\StringInterface objects matching the conditions. + */ + public function getStrings(array $conditions = array(), array $options = array(), array $fields = array()); + + /** + * Loads multiple string translation objects. + * + * In order to produce a query as fast as possible we must pass the exact + * fields we need to load only that ones. + * + * @see Drupal\locale\StringStorageInterface::getStrings() + * + * @param array $conditions + * (optional) Array with simple field conditions. Defaults to no conditions + * which means that it will load all source strings. + * @param array $options + * (optional) An associative array of additional options. It may contain + * any of the options defined by getStrings(). + * @param array $fields + * (optional) List of field names to retrieve. Defaults to all fields. + * + * @return array + * Array of Drupal\locale\StringInterface objects matching the conditions. + */ + public function getTranslations(array $conditions = array(), array $options = array(), array $fields = array()); + + /** + * Checks whether the string version matches a given version, fix it if not. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * @param string $version + * Drupal version to check against. + */ + public function checkVersion($string, $version); + + /** + * Save string object to storage. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * + * @return Drupal\locale\StringStorageInterface + * The called object. + */ + public function save($string); + + /** + * Delete string from storage. + * + * @param Drupal\locale\StringInterface $string + * The string object. + * + * @return Drupal\locale\StringStorageInterface + * The called object. + */ + public function delete($string); + + /** + * Delete all translations for a language. + * + * @param string $langcode + * Language code. + */ + public function deleteLanguage($langcode); + + /** + * Counts source strings. + * + * @return int + * The number of source strings contained in the storage. + */ + public function countStrings(); + + /** + * Counts translations. + * + * @return array + * The number of translations for each language indexed by language code. + */ + public function countTranslations(); + + /** + * Creates a source string object bound to this storage but not saved. + * + * @param array $values + * (optional) Array with initial values. Defaults to empty array. + * + * @return Drupal\locale\LocaleSource + * New source string object. + */ + public function createString($values = array()); + + /** + * Creates a string translation object bound to this storage but not saved. + * + * @param array $values + * (optional) Array with initial values. Defaults to empty array. + * + * @return Drupal\locale\LocaleTranslation + * New string translation object. + */ + public function createTranslation($values = array()); +} diff --git a/core/modules/locale/lib/Drupal/locale/Tests/LocaleStringTest.php b/core/modules/locale/lib/Drupal/locale/Tests/LocaleStringTest.php new file mode 100644 index 0000000..e532727 --- /dev/null +++ b/core/modules/locale/lib/Drupal/locale/Tests/LocaleStringTest.php @@ -0,0 +1,201 @@ + 'String storage and objects', + 'description' => 'Tests the locale string storage, string objects and data API.', + 'group' => 'Locale', + ); + } + + function setUp() { + parent::setUp(); + // Add a default locale storage for all these tests. + $this->storage = locale_storage(); + // Create two languages: Spanish and German. + foreach (array('es', 'de') as $langcode) { + $language = new Language(array('langcode' => $langcode)); + $languages[$langcode] = language_save($language); + } + } + + /** + * Test CRUD API. + */ + function testStringCRUDAPI() { + // Create source string. + $source = $this->buildSourceString(); + $source->save(); + $this->assertTrue($source->lid, format_string('Successfully created string %string', array('%string' => $source->source))); + + // Load strings by lid and source. + $string1 = $this->storage->findString(array('lid' => $source->lid)); + $this->assertEqual($source, $string1, 'Successfully retrieved string by identifier.'); + $string2 = $this->storage->findString(array('source' => $source->source, 'context' => $source->context)); + $this->assertEqual($source, $string2, 'Successfully retrieved string by source and context.'); + $string3 = $this->storage->findString(array('source' => $source->source, 'context' => '')); + $this->assertFalse($string3, 'Cannot retrieve string with wrong context.'); + + // Check version handling and updating. + $this->assertEqual($source->version, 'none', 'String originally created without version.'); + $this->storage->checkVersion($source, VERSION); + $string = $this->storage->findString(array('lid' => $source->lid)); + $this->assertEqual($source->version, VERSION, 'Checked and updated string version to Drupal version.'); + + // Create translation and find it by lid and source. + $langcode = 'es'; + $translation = $this->createTranslation($source, $langcode); + $this->assertEqual($translation->customized, LOCALE_NOT_CUSTOMIZED, 'Translation created as not customized by default.'); + $string1 = $this->storage->findTranslation(array('language' => $langcode, 'lid' => $source->lid)); + $this->assertEqual($string1->translation, $translation->translation, 'Successfully loaded translation by string identifier.'); + $string2 = $this->storage->findTranslation(array('language' => $langcode, 'source' => $source->source, 'context' => $source->context)); + $this->assertEqual($string2->translation, $translation->translation, 'Successfully loaded translation by source and context.'); + $translation + ->setCustomized() + ->save(); + $translation = $this->storage->findTranslation(array('language' => $langcode, 'lid' => $source->lid)); + $this->assertEqual($translation->customized, LOCALE_CUSTOMIZED, 'Translation successfully marked as customized.'); + + // Delete translation. + $translation->delete(); + $deleted = $this->storage->findTranslation(array('language' => $langcode, 'lid' => $source->lid)); + $this->assertFalse(isset($deleted->translation), 'Successfully deleted translation string.'); + + // Create some translations and then delete string and all of its translations. + $lid = $source->lid; + $translations = $this->createAllTranslations($source); + $search = $this->storage->getTranslations(array('lid' => $source->lid)); + $this->assertEqual(count($search), 3 , 'Created and retrieved all translations for our source string.'); + + $source->delete(); + $string = $this->storage->findString(array('lid' => $lid)); + $this->assertFalse($string, 'Successfully deleted source string.'); + $deleted = $search = $this->storage->getTranslations(array('lid' => $lid)); + $this->assertFalse($deleted, 'Successfully deleted all translation strings.'); + } + + /** + * Test Search API loading multiple objects. + */ + function testStringSearchAPI() { + $language_count = 3; + // Strings 1 and 2 will have some common prefix. + // Source 1 will have all translations, not customized. + // Source 2 will have all translations, customized. + // Source 3 will have no translations. + $prefix = $this->randomName(100); + $source1 = $this->buildSourceString(array('source' => $prefix . $this->randomName(100)))->save(); + $source2 = $this->buildSourceString(array('source' => $prefix . $this->randomName(100)))->save(); + $source3 = $this->buildSourceString()->save(); + // Load all source strings. + $strings = $this->storage->getStrings(array()); + $this->assertEqual(count($strings), 3 , 'Found 3 source strings in the database.'); + // Load all source strings matching a given string + $filter_options['filters'] = array('source' => $prefix); + $strings = $this->storage->getStrings(array(), $filter_options); + $this->assertEqual(count($strings), 2 , 'Found 2 strings using some string filter.'); + + // Not customized translations. + $translate1 = $this->createAllTranslations($source1); + // Customized translations. + $translate2 = $this->createAllTranslations($source2, array('customized' => LOCALE_CUSTOMIZED)); + // Try quick search function with different field combinations. + $langcode = 'es'; + $found = locale_storage()->findTranslation(array('language' => $langcode, 'source' => $source1->source, 'context' => $source1->context), array('translation')); + $this->assertTrue($found && isset($found->language) && !isset($found->customized) && !$found->isNew(), 'Translation found with only the right fields.'); + $this->assertEqual($found->translation, $translate1[$langcode]->translation, 'Found the right translation.'); + // Now try a translation not found. + $found = locale_storage()->findTranslation(array('language' => $langcode, 'source' => $source3->source, 'context' => $source3->context), array('translation')); + $this->assertTrue($found && $found->lid == $source3->lid && !isset($found->translation) && $found->isNew(), 'Missing translation found with only the right fields.'); + + // Load all translations. For next queries we'll be loading only translated strings. + $only_translated = array('untranslated' => FALSE); + $only_untranslated = array('translated' => FALSE); + $translations = $this->storage->getTranslations(array(), $only_translated); + $this->assertEqual(count($translations), 2 * $language_count , 'Created and retrieved all translations for source strings.'); + + // Load all customized translations. + $translations = $this->storage->getTranslations(array('customized' => LOCALE_CUSTOMIZED), $only_translated); + $this->assertEqual(count($translations), $language_count , 'Retrieved all customized translations for source strings.'); + + // Load all Spanish customized translations + $translations = $this->storage->getTranslations(array('language' => 'es', 'customized' => LOCALE_CUSTOMIZED), $only_translated); + $this->assertEqual(count($translations), 1 , 'Found only Spanish and customized translations.'); + + // Load all source strings without translation (1). + $translations = $this->storage->getStrings(array(), $only_untranslated); + $this->assertEqual(count($translations), 1 , 'Found 1 source string without translations.'); + + // Load Spanish translations using string filter. + $filter_options['filters'] = array('source' => $prefix); + $translations = $this->storage->getTranslations(array('language' => 'es'), $filter_options); + $this->assertEqual(count($strings), 2 , 'Found 2 translations using some string filter.'); + + } + + /** + * Creates random source string object. + */ + function buildSourceString($values = array()) { + return $this->storage->createString($values += array( + 'source' => $this->randomName(100), + 'context' => $this->randomName(20), + )); + } + + /** + * Creates translations for source string and all languages. + */ + function createAllTranslations($source, $values = array()) { + $list = array(); + foreach (language_list() as $language) { + $list[$language->langcode] = $this->createTranslation($source, $language->langcode, $values); + } + return $list; + } + + /** + * Creates single translation for source string. + */ + function createTranslation($source, $langcode, $values = array()) { + return $this->storage->createTranslation($values += array( + 'lid' => $source->lid, + 'language' => $langcode, + 'translation' => $this->randomName(100), + )); + } +} diff --git a/core/modules/locale/locale.module b/core/modules/locale/locale.module index a32d47d..f55be76 100644 --- a/core/modules/locale/locale.module +++ b/core/modules/locale/locale.module @@ -13,7 +13,10 @@ use Drupal\locale\LocaleLookup; use Drupal\locale\LocaleConfigSubscriber; +use Drupal\locale\LocaleSource; +use Drupal\locale\StringDatabaseStorage; use Drupal\locale\TranslationsStream; +use Drupal\Core\Database\Database; /** * Regular expression pattern used to localize JavaScript strings. @@ -213,9 +216,7 @@ function locale_language_update($language) { */ function locale_language_delete($language) { // Remove translations. - db_delete('locales_target') - ->condition('language', $language->langcode) - ->execute(); + locale_storage()->deleteLanguage($language->langcode); // Remove interface translation files. module_load_include('inc', 'locale', 'locale.bulk'); @@ -281,7 +282,7 @@ function locale($string = NULL, $context = NULL, $langcode = NULL) { // Strings are cached by langcode, context and roles, using instances of the // LocaleLookup class to handle string lookup and caching. if (!isset($locale_t[$langcode][$context]) && isset($language_interface)) { - $locale_t[$langcode][$context] = new LocaleLookup($langcode, $context); + $locale_t[$langcode][$context] = new LocaleLookup($langcode, $context, locale_storage()); } return ($locale_t[$langcode][$context][$string] === TRUE ? $string : $locale_t[$langcode][$context][$string]); } @@ -294,6 +295,20 @@ function locale_reset() { } /** + * Gets the locale storage controller class . + * + * @return Drupal\locale\StringStorageInterface + */ +function locale_storage() { + $storage = &drupal_static(__FUNCTION__); + if (!isset($storage)) { + $options = array('target' => 'default'); + $storage = new StringDatabaseStorage(Database::getConnection($options['target']), $options); + } + return $storage; +} + +/** * Returns plural form index for a specific number. * * The index is computed from the formula of this language. @@ -519,16 +534,16 @@ function locale_library_info_alter(&$libraries, $module) { function locale_form_language_admin_overview_form_alter(&$form, &$form_state) { $languages = $form['languages']['#languages']; - $total_strings = db_query("SELECT COUNT(*) FROM {locales_source}")->fetchField(); + $total_strings = locale_storage()->countStrings(); $stats = array_fill_keys(array_keys($languages), array()); // If we have source strings, count translations and calculate progress. if (!empty($total_strings)) { - $translations = db_query("SELECT COUNT(*) AS translated, t.language FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid GROUP BY language"); - foreach ($translations as $data) { - $stats[$data->language]['translated'] = $data->translated; - if ($data->translated > 0) { - $stats[$data->language]['ratio'] = round($data->translated / $total_strings * 100, 2); + $translations = locale_storage()->countTranslations(); + foreach ($translations as $langcode => $translated) { + $stats[$langcode]['translated'] = $translated; + if ($translated > 0) { + $stats[$langcode]['ratio'] = round($translated / $total_strings * 100, 2); } } } @@ -760,7 +775,7 @@ function _locale_parse_js_file($filepath) { $string = implode('', preg_split('~(? $string, ':context' => $context))->fetchObject(); + $source = locale_storage()->findSource($string, $context, array('location')); if ($source) { // We already have this source string and now have to add the location // to the location column, if this file is not yet present in there. @@ -771,23 +786,17 @@ function _locale_parse_js_file($filepath) { $locations = implode('; ', $locations); // Save the new locations string to the database. - db_update('locales_source') - ->fields(array( - 'location' => $locations, - )) - ->condition('lid', $source->lid) - ->execute(); + $source->setValues(array('location' => $locations)) + ->save(); } } else { // We don't have the source string yet, thus we insert it into the database. - db_insert('locales_source') - ->fields(array( - 'location' => $filepath, - 'source' => $string, - 'context' => $context, - )) - ->execute(); + locale_storage()->createString(array( + 'location' => $filepath, + 'source' => $string, + 'context' => $context, + ))->save(); } } } @@ -846,10 +855,11 @@ function _locale_rebuild_js($langcode = NULL) { // Construct the array for JavaScript translations. // Only add strings with a translation to the translations array. - $result = db_query("SELECT s.lid, s.source, s.context, t.translation FROM {locales_source} s INNER JOIN {locales_target} t ON s.lid = t.lid AND t.language = :language WHERE s.location LIKE '%.js%'", array(':language' => $language->langcode)); - + $options['filters']['location'] = '%.js%'; + $fields = array('source', 'context', 'translation'); + $conditions['language'] = $language->langcode; $translations = array(); - foreach ($result as $data) { + foreach (locale_storage()->getTranslations($conditions, $options, $fields) as $data) { $translations[$data->context][$data->source] = $data->translation; } diff --git a/core/modules/locale/locale.pages.inc b/core/modules/locale/locale.pages.inc index 5fdba76..7d7918f 100644 --- a/core/modules/locale/locale.pages.inc +++ b/core/modules/locale/locale.pages.inc @@ -5,6 +5,8 @@ * Interface translation summary, editing and deletion user interfaces. */ +use Drupal\locale\LocaleSource; +use Drupal\locale\LocaleTranslation; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** @@ -18,42 +20,42 @@ function locale_translate_page() { } /** - * Build a string search query. + * Builds a string search query and returns an array of string objects. + * + * @return array + * Array of Drupal\locale\LocaleTranslation objects. */ -function locale_translate_query() { +function locale_translate_filter_load_strings() { $filter_values = locale_translate_filter_values(); - $sql_query = db_select('locales_source', 's'); // Language is sanitized to be one of the possible options in // locale_translate_filter_values(). - $sql_query->leftJoin('locales_target', 't', "t.lid = s.lid AND t.language = :langcode", array(':langcode' => $filter_values['langcode'])); - $sql_query->fields('s', array('source', 'location', 'context', 'lid')); - $sql_query->fields('t', array('translation', 'language', 'customized')); - - if (!empty($filter_values['string'])) { - $sql_query->condition(db_or() - ->condition('s.source', '%' . db_like($filter_values['string']) . '%', 'LIKE') - ->condition('t.translation', '%' . db_like($filter_values['string']) . '%', 'LIKE') - ); - } + $conditions = array('language' => $filter_values['langcode']); + $options = array('pager limit' => 30, 'translated' => TRUE, 'untranslated' => TRUE); - // Add translation status conditions. + // Add translation status conditions and options. switch ($filter_values['translation']) { case 'translated': - $sql_query->isNotNull('t.translation'); + $options['untranslated'] = FALSE; if ($filter_values['customized'] != 'all') { - $sql_query->condition('t.customized', $filter_values['customized']); + $conditions['customized'] = $filter_values['customized']; } break; case 'untranslated': - $sql_query->isNull('t.translation'); + $options['translated'] = FALSE; break; } - $sql_query = $sql_query->extend('Drupal\Core\Database\Query\PagerSelectExtender')->limit(30); - return $sql_query->execute(); + if (!empty($filter_values['string'])) { + $options['filters']['source'] = $filter_values['string']; + if ($options['translated']) { + $options['filters']['translation'] = $filter_values['string']; + } + } + + return locale_storage()->getTranslations($conditions, $options); } /** @@ -270,14 +272,16 @@ function locale_translate_edit_form($form, &$form_state) { ); if (isset($langcode)) { - $strings = locale_translate_query(); + $strings = locale_translate_filter_load_strings(); $plural_formulas = variable_get('locale_translation_plurals', array()); foreach ($strings as $string) { + // Cast into source string, will do for our purposes. + $source = new LocaleSource($string); // Split source to work with plural values. - $source_array = explode(LOCALE_PLURAL_DELIMITER, $string->source); - $translation_array = explode(LOCALE_PLURAL_DELIMITER, $string->translation); + $source_array = $source->getPlurals(); + $translation_array = $string->getPlurals(); if (count($source_array) == 1) { // Add original string value and mark as non-plural. $form['strings'][$string->lid]['plural'] = array( @@ -390,9 +394,8 @@ function locale_translate_edit_form_validate($form, &$form_state) { function locale_translate_edit_form_submit($form, &$form_state) { $langcode = $form_state['values']['langcode']; foreach ($form_state['values']['strings'] as $lid => $translations) { - // Serialize plural variants in one string by LOCALE_PLURAL_DELIMITER. - $translation_new = implode(LOCALE_PLURAL_DELIMITER, $translations['translations']); - $translation_old = db_query("SELECT translation FROM {locales_target} WHERE lid = :lid AND language = :language", array(':lid' => $lid, ':language' => $langcode))->fetchField(); + // Get target string, that may be NULL if there's no translation. + $target = locale_storage()->findTranslation(array('language' => $langcode, 'lid' => $lid)); // No translation when all strings are empty. $has_translation = FALSE; foreach ($translations['translations'] as $string) { @@ -403,35 +406,15 @@ function locale_translate_edit_form_submit($form, &$form_state) { } if ($has_translation) { // Only update or insert if we have a value to use. - if (!empty($translation_old) && $translation_old != $translation_new) { - db_update('locales_target') - ->fields(array( - 'translation' => $translation_new, - 'customized' => LOCALE_CUSTOMIZED, - )) - ->condition('lid', $lid) - ->condition('language', $langcode) - ->execute(); - } - if (empty($translation_old)) { - db_insert('locales_target') - ->fields(array( - 'lid' => $lid, - 'translation' => $translation_new, - 'language' => $langcode, - 'customized' => LOCALE_CUSTOMIZED, - )) - ->execute(); - } + $target = $target ? $target : new LocaleTranslation(array('lid' => $lid, 'language' => $langcode)); + $target->setPlurals($translations['translations']) + ->setCustomized() + ->save(); } - elseif (!empty($translation_old)) { + elseif ($target) { // Empty translation entered: remove existing entry from database. - db_delete('locales_target') - ->condition('lid', $lid) - ->condition('language', $langcode) - ->execute(); + $target->delete(); } - } drupal_set_message(t('The strings have been saved.'));