Commit 477c06c4 authored by alexpott's avatar alexpott

Issue #1498674 by plach, das-peter, Schnitzel, dawehner, YesCT, attiks,...

Issue #1498674 by plach, das-peter, Schnitzel, dawehner, YesCT, attiks, Berdir, Gábor Hojtsy, Soul88, Carsten Müller: Refactor node properties to multilingual.
parent 1d266317
......@@ -34,20 +34,20 @@
* results that need to be presented on multiple pages, and the Tablesort
* Extender for generating appropriate queries for sortable tables.
*
* For example, one might wish to return a list of the most recent 10 nodes
* For example, one might wish to return a list of the most recent 10 rows
* authored by a given user. Instead of directly issuing the SQL query
* @code
* SELECT n.nid, n.title, n.created FROM node n WHERE n.uid = $uid LIMIT 0, 10;
* SELECT e.id, e.title, e.created FROM example e WHERE e.uid = $uid LIMIT 0, 10;
* @endcode
* one would instead call the Drupal functions:
* @code
* $result = db_query_range('SELECT n.nid, n.title, n.created
* FROM {node} n WHERE n.uid = :uid', 0, 10, array(':uid' => $uid));
* $result = db_query_range('SELECT e.id, e.title, e.created
* FROM {example} e WHERE e.uid = :uid', 0, 10, array(':uid' => $uid));
* foreach ($result as $record) {
* // Perform operations on $record->title, etc. here.
* }
* @endcode
* Curly braces are used around "node" to provide table prefixing via
* Curly braces are used around "example" to provide table prefixing via
* DatabaseConnection::prefixTables(). The explicit use of a user ID is pulled
* out into an argument passed to db_query() so that SQL injection attacks
* from user input can be caught and nullified. The LIMIT syntax varies between
......@@ -69,7 +69,7 @@
*
* Named placeholders begin with a colon followed by a unique string. Example:
* @code
* SELECT nid, title FROM {node} WHERE uid=:uid;
* SELECT id, title FROM {example} WHERE uid=:uid;
* @endcode
*
* ":uid" is a placeholder that will be replaced with a literal value when
......@@ -81,7 +81,7 @@
*
* Unnamed placeholders are simply a question mark. Example:
* @code
* SELECT nid, title FROM {node} WHERE uid=?;
* SELECT id, title FROM {example} WHERE uid=?;
* @endcode
*
* In this case, the array of arguments must be an indexed array of values to
......@@ -91,11 +91,11 @@
* running a LIKE query the SQL wildcard character, %, should be part of the
* value, not the query itself. Thus, the following is incorrect:
* @code
* SELECT nid, title FROM {node} WHERE title LIKE :title%;
* SELECT id, title FROM {example} WHERE title LIKE :title%;
* @endcode
* It should instead read:
* @code
* SELECT nid, title FROM {node} WHERE title LIKE :title;
* SELECT id, title FROM {example} WHERE title LIKE :title;
* @endcode
* and the value for :title should include a % as appropriate. Again, note the
* lack of quotation marks around :title. Because the value is not inserted
......@@ -109,7 +109,7 @@
* object-oriented API for defining a query structurally. For example, rather
* than:
* @code
* INSERT INTO node (nid, title, body) VALUES (1, 'my title', 'my body');
* INSERT INTO {example} (id, uid, path, name) VALUES (1, 2, 'home', 'Home path');
* @endcode
* one would instead write:
* @code
......
......@@ -4921,26 +4921,26 @@ function _form_set_attributes(&$element, $class = array()) {
* $context['message'] = check_plain($node->label());
* }
*
* // More advanced example: multi-step operation - load all nodes, five by five
* // A more advanced example is a multi-step operation that loads all rows,
* // five by five.
* function my_function_2(&$context) {
* if (empty($context['sandbox'])) {
* $context['sandbox']['progress'] = 0;
* $context['sandbox']['current_node'] = 0;
* $context['sandbox']['max'] = db_query('SELECT COUNT(DISTINCT nid) FROM {node}')->fetchField();
* $context['sandbox']['current_id'] = 0;
* $context['sandbox']['max'] = db_query('SELECT COUNT(DISTINCT id) FROM {example}')->fetchField();
* }
* $limit = 5;
* $result = db_select('node')
* ->fields('node', array('nid'))
* ->condition('nid', $context['sandbox']['current_node'], '>')
* ->orderBy('nid')
* $result = db_select('example')
* ->fields('example', array('id'))
* ->condition('id', $context['sandbox']['current_id'], '>')
* ->orderBy('id')
* ->range(0, $limit)
* ->execute();
* foreach ($result as $row) {
* $node = node_load($row->nid, TRUE);
* $context['results'][] = $node->nid . ' : ' . check_plain($node->label());
* $context['results'][] = $row->id . ' : ' . check_plain($row->title);
* $context['sandbox']['progress']++;
* $context['sandbox']['current_node'] = $node->nid;
* $context['message'] = check_plain($node->label());
* $context['sandbox']['current_id'] = $row->id;
* $context['message'] = check_plain($row->title);
* }
* if ($context['sandbox']['progress'] != $context['sandbox']['max']) {
* $context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
......
......@@ -1468,9 +1468,12 @@ function menu_tree_collect_node_links(&$tree, &$node_links) {
function menu_tree_check_access(&$tree, $node_links = array()) {
if ($node_links) {
$nids = array_keys($node_links);
$select = db_select('node', 'n');
$select = db_select('node_field_data', 'n');
$select->addField('n', 'nid');
// @todo This should be actually filtering on the desired node status field
// language and just fall back to the default language.
$select->condition('n.status', 1);
$select->condition('n.nid', $nids, 'IN');
$select->addTag('node_access');
$nids = $select->execute()->fetchCol();
......
......@@ -433,20 +433,8 @@ function drupal_write_record($table, &$record, $primary_keys = array()) {
// Type cast to proper datatype, except when the value is NULL and the
// column allows this.
//
// MySQL PDO silently casts e.g. FALSE and '' to 0 when inserting the value
// into an integer column, but PostgreSQL PDO does not. Also type cast NULL
// when the column does not allow this.
if (isset($object->$field) || !empty($info['not null'])) {
if ($info['type'] == 'int' || $info['type'] == 'serial') {
$fields[$field] = (int) $fields[$field];
}
elseif ($info['type'] == 'float') {
$fields[$field] = (float) $fields[$field];
}
else {
$fields[$field] = (string) $fields[$field];
}
$fields[$field] = drupal_schema_get_field_value($info, $fields[$field]);
}
}
......@@ -521,6 +509,34 @@ function drupal_write_record($table, &$record, $primary_keys = array()) {
return $return;
}
/**
* Typecasts values to proper datatypes.
*
* MySQL PDO silently casts, e.g. FALSE and '' to 0, when inserting the value
* into an integer column, but PostgreSQL PDO does not. Look up the schema
* information and use that to correctly typecast the value.
*
* @param array $info
* An array describing the schema field info.
* @param mixed $value
* The value to be converted.
*
* @return mixed
* The converted value.
*/
function drupal_schema_get_field_value(array $info, $value) {
if ($info['type'] == 'int' || $info['type'] == 'serial') {
$value = (int) $value;
}
elseif ($info['type'] == 'float') {
$value = (float) $value;
}
else {
$value = (string) $value;
}
return $value;
}
/**
* @} End of "addtogroup schemaapi".
*/
......@@ -32,16 +32,16 @@ public function orderRandom() {
* yet selected.
*
* @code
* $query = db_select('node', 'n');
* $query->join('node_revision', 'nr', 'n.vid = nr.vid');
* $query = db_select('example', 'e');
* $query->join('example_revision', 'er', 'e.vid = er.vid');
* $query
* ->distinct()
* ->fields('n')
* ->fields('e')
* ->orderBy('timestamp');
* @endcode
*
* In this query, it is not possible (without relying on the schema) to know
* whether timestamp belongs to node_revisions and needs to be added or
* whether timestamp belongs to example_revision and needs to be added or
* belongs to node and is already selected. Queries like this will need to be
* corrected in the original query by adding an explicit call to
* SelectQuery::addField() or SelectQuery::fields().
......
......@@ -32,7 +32,7 @@
* The following keys are defined:
* - 'description': A string in non-markup plain text describing this table
* and its purpose. References to other tables should be enclosed in
* curly-brackets. For example, the node_revisions table
* curly-brackets. For example, the node_field_revision table
* description field might contain "Stores per-revision title and
* body data for each {node}."
* - 'fields': An associative array ('fieldname' => specification)
......@@ -42,7 +42,7 @@
* and its purpose. References to other tables should be enclosed in
* curly-brackets. For example, the node table vid field
* description might contain "Always holds the largest (most
* recent) {node_revision}.vid value for this nid."
* recent) {node_field_revision}.vid value for this nid."
* - 'type': The generic datatype: 'char', 'varchar', 'text', 'blob', 'int',
* 'float', 'numeric', or 'serial'. Most types just map to the according
* database engine specific datatypes. Use 'serial' for auto incrementing
......@@ -150,7 +150,7 @@
* ),
* 'foreign keys' => array(
* 'node_revision' => array(
* 'table' => 'node_revision',
* 'table' => 'node_field_revision',
* 'columns' => array('vid' => 'vid'),
* ),
* 'node_author' => array(
......
......@@ -289,7 +289,7 @@ public function loadRevision($revision_id) {
// which attaches fields (if supported by the entity type) and calls the
// entity type specific load callback, for example hook_node_load().
if (!empty($queried_entities)) {
$this->attachLoad($queried_entities, TRUE);
$this->attachLoad($queried_entities, $revision_id);
}
return reset($queried_entities);
}
......
......@@ -14,6 +14,7 @@
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\DatabaseStorageController;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Component\Uuid\Uuid;
use Drupal\Core\Database\Connection;
......@@ -153,6 +154,55 @@ protected function buildPropertyQuery(QueryInterface $entity_query, array $value
parent::buildPropertyQuery($entity_query, $values);
}
/**
* {@inheritdoc}
*/
protected function buildQuery($ids, $revision_id = FALSE) {
$query = $this->database->select($this->entityInfo['base_table'], 'base');
$is_revision_query = $this->revisionKey && ($revision_id || !$this->dataTable);
$query->addTag($this->entityType . '_load_multiple');
if ($revision_id) {
$query->join($this->revisionTable, 'revision', "revision.{$this->idKey} = base.{$this->idKey} AND revision.{$this->revisionKey} = :revisionId", array(':revisionId' => $revision_id));
}
elseif ($is_revision_query) {
$query->join($this->revisionTable, 'revision', "revision.{$this->revisionKey} = base.{$this->revisionKey}");
}
// Add fields from the {entity} table.
$entity_fields = drupal_schema_fields_sql($this->entityInfo['base_table']);
if ($is_revision_query) {
// Add all fields from the {entity_revision} table.
$entity_revision_fields = drupal_map_assoc(drupal_schema_fields_sql($this->entityInfo['revision_table']));
// The ID field is provided by entity, so remove it.
unset($entity_revision_fields[$this->idKey]);
// Remove all fields from the base table that are also fields by the same
// name in the revision table.
$entity_field_keys = array_flip($entity_fields);
foreach ($entity_revision_fields as $key => $name) {
if (isset($entity_field_keys[$name])) {
unset($entity_fields[$entity_field_keys[$name]]);
}
}
$query->fields('revision', $entity_revision_fields);
// Compare revision ID of the base and revision table, if equal then this
// is the default revision.
$query->addExpression('base.' . $this->revisionKey . ' = revision.' . $this->revisionKey, 'isDefaultRevision');
}
$query->fields('base', $entity_fields);
if ($ids) {
$query->condition("base.{$this->idKey}", $ids, 'IN');
}
return $query;
}
/**
* Overrides DatabaseStorageController::attachLoad().
*
......@@ -199,16 +249,20 @@ protected function attachLoad(&$queried_entities, $load_revision = FALSE) {
protected function mapFromStorageRecords(array $records, $load_revision = FALSE) {
$entities = array();
foreach ($records as $id => $record) {
$values = array();
$entities[$id] = array();
foreach ($record as $name => $value) {
// Skip the item delta and item value levels but let the field assign
// the value as suiting. This avoids unnecessary array hierarchies and
// saves memory here.
$values[$name][Language::LANGCODE_DEFAULT] = $value;
$entities[$id][$name][Language::LANGCODE_DEFAULT] = $value;
}
// If we have no multilingual values we can instantiate entity objecs
// right now, otherwise we need to collect all the field values first.
if (!$this->dataTable) {
$bundle = $this->bundleKey ? $record->{$this->bundleKey} : FALSE;
// Turn the record into an entity class.
$entities[$id] = new $this->entityClass($entities[$id], $this->entityType, $bundle);
}
$bundle = $this->bundleKey ? $record->{$this->bundleKey} : FALSE;
// Turn the record into an entity class.
$entities[$id] = new $this->entityClass($values, $this->entityType, $bundle);
}
$this->attachPropertyData($entities, $load_revision);
return $entities;
......@@ -219,50 +273,65 @@ protected function mapFromStorageRecords(array $records, $load_revision = FALSE)
*
* @param array &$entities
* Associative array of entities, keyed on the entity ID.
* @param boolean $load_revision
* (optional) TRUE if the revision should be loaded, defaults to FALSE.
* @param int $revision_id
* (optional) The revision to be loaded. Defaults to FALSE.
*/
protected function attachPropertyData(array &$entities, $load_revision = FALSE) {
protected function attachPropertyData(array &$entities, $revision_id = FALSE) {
if ($this->dataTable) {
$query = $this->database->select($this->dataTable, 'data', array('fetch' => PDO::FETCH_ASSOC))
// If a revision table is available, we need all the properties of the
// latest revision. Otherwise we fall back to the data table.
$table = $this->revisionTable ?: $this->dataTable;
$query = $this->database->select($table, 'data', array('fetch' => PDO::FETCH_ASSOC))
->fields('data')
->condition($this->idKey, array_keys($entities))
->orderBy('data.' . $this->idKey);
if ($load_revision) {
// Get revision ID's.
$revision_ids = array();
foreach ($entities as $id => $entity) {
$revision_ids[] = $entity->get($this->revisionKey)->value;
if ($this->revisionTable) {
if ($revision_id) {
$query->condition($this->revisionKey, $revision_id);
}
else {
// Get the revision IDs.
$revision_ids = array();
foreach ($entities as $id => $values) {
$revision_ids[] = $values[$this->revisionKey];
}
$query->condition($this->revisionKey, $revision_ids);
}
$query->condition($this->revisionKey, $revision_ids);
}
$data = $query->execute();
// Fetch the field definitions to check which field is translatable.
$data = $query->execute();
$field_definition = $this->getFieldDefinitions(array());
$data_fields = array_flip(drupal_schema_fields_sql($this->entityInfo['data_table']));
if ($this->revisionTable) {
$data_fields = array_flip(array_diff(drupal_schema_fields_sql($this->entityInfo['revision_table']), drupal_schema_fields_sql($this->entityInfo['base_table'])));
}
else {
$data_fields = array_flip(drupal_schema_fields_sql($this->entityInfo['data_table']));
}
foreach ($data as $values) {
$id = $values[$this->idKey];
// Field values in default language are stored with
// Language::LANGCODE_DEFAULT as key.
$langcode = empty($values['default_langcode']) ? $values['langcode'] : Language::LANGCODE_DEFAULT;
$translation = $entities[$id]->getTranslation($langcode);
foreach ($field_definition as $name => $definition) {
// Set translatable properties only.
if (isset($data_fields[$name]) && !empty($definition['translatable'])) {
// @todo Figure out how to determine which property has to be set.
// Currently it's guessing, and guessing is evil!
$property_definition = $translation->{$name}->getPropertyDefinitions();
$translation->{$name}->{key($property_definition)} = $values[$name];
}
// Avoid initializing configurable fields before loading them.
elseif (!empty($definition['configurable'])) {
unset($entities[$id]->fields[$name]);
// Set only translatable properties, unless we are dealing with a
// revisable entity, in which case we did not load the untranslatable
// data before.
$translatable = !empty($definition['translatable']);
if (isset($data_fields[$name]) && ($this->revisionTable || $translatable)) {
$entities[$id][$name][$langcode] = $values[$name];
}
}
}
foreach ($entities as $id => $values) {
$bundle = $this->bundleKey ? $values[$this->bundleKey][Language::LANGCODE_DEFAULT] : FALSE;
// Turn the record into an entity class.
$entities[$id] = new $this->entityClass($values, $this->entityType, $bundle);
}
}
}
......@@ -352,32 +421,53 @@ public function save(EntityInterface $entity) {
* The revision id.
*/
protected function saveRevision(EntityInterface $entity) {
$record = $this->mapToRevisionStorageRecord($entity);
$return = $entity->id();
$default_langcode = $entity->language()->langcode;
// When saving a new revision, set any existing revision ID to NULL so as to
// ensure that a new revision will actually be created.
if ($entity->isNewRevision() && isset($record->{$this->revisionKey})) {
$record->{$this->revisionKey} = NULL;
if (!$entity->isNewRevision()) {
// Delete to handle removed values.
$this->database->delete($this->revisionTable)
->condition($this->idKey, $entity->id())
->condition($this->revisionKey, $entity->getRevisionId())
->execute();
}
$this->preSaveRevision($record, $entity);
$languages = $this->dataTable ? $entity->getTranslationLanguages(TRUE) : array($default_langcode => $entity->language());
foreach ($languages as $langcode => $language) {
$translation = $entity->getTranslation($langcode, FALSE);
$record = $this->mapToRevisionStorageRecord($translation);
$record->langcode = $langcode;
$record->default_langcode = $langcode == $default_langcode;
// When saving a new revision, set any existing revision ID to NULL so as
// to ensure that a new revision will actually be created.
if ($entity->isNewRevision() && isset($record->{$this->revisionKey})) {
$record->{$this->revisionKey} = NULL;
}
if ($entity->isNewRevision()) {
drupal_write_record($this->revisionTable, $record);
if ($entity->isDefaultRevision()) {
$this->database->update($this->entityInfo['base_table'])
->fields(array($this->revisionKey => $record->{$this->revisionKey}))
->condition($this->idKey, $record->{$this->idKey})
->execute();
$this->preSaveRevision($record, $entity);
if ($entity->isNewRevision()) {
drupal_write_record($this->revisionTable, $record);
if ($entity->isDefaultRevision()) {
$this->database->update($this->entityInfo['base_table'])
->fields(array($this->revisionKey => $record->{$this->revisionKey}))
->condition($this->idKey, $record->{$this->idKey})
->execute();
}
$entity->setNewRevision(FALSE);
}
$entity->setNewRevision(FALSE);
}
else {
drupal_write_record($this->revisionTable, $record, $this->revisionKey);
else {
// @todo Use multiple insertions to improve performance.
drupal_write_record($this->revisionTable, $record);
}
// Make sure to update the new revision key for the entity.
$entity->{$this->revisionKey}->value = $record->{$this->revisionKey};
$return = $record->{$this->revisionKey};
}
// Make sure to update the new revision key for the entity.
$entity->{$this->revisionKey}->value = $record->{$this->revisionKey};
return $record->{$this->revisionKey};
return $return;
}
/**
......@@ -453,10 +543,11 @@ protected function mapToStorageRecord(EntityInterface $entity) {
* @return \stdClass
* The record to store.
*/
protected function mapToRevisionStorageRecord(EntityInterface $entity) {
protected function mapToRevisionStorageRecord(ComplexDataInterface $entity) {
$record = new \stdClass();
$definitions = $entity->getPropertyDefinitions();
foreach (drupal_schema_fields_sql($this->entityInfo['revision_table']) as $name) {
if (isset($entity->$name->value)) {
if (isset($definitions[$name]) && isset($entity->$name->value)) {
$record->$name = $entity->$name->value;
}
}
......@@ -479,10 +570,14 @@ protected function mapToDataStorageRecord(EntityInterface $entity, $langcode) {
// Don't use strict mode, this way there's no need to do checks here, as
// non-translatable properties are replicated for each language.
$translation = $entity->getTranslation($langcode, FALSE);
$definitions = $translation->getPropertyDefinitions();
$schema = drupal_get_schema($this->entityInfo['data_table']);
$record = new \stdClass();
foreach (drupal_schema_fields_sql($this->entityInfo['data_table']) as $name) {
$record->$name = $translation->$name->value;
$info = $schema['fields'][$name];
$value = isset($definitions[$name]) && isset($translation->$name->value) ? $translation->$name->value : NULL;
$record->$name = drupal_schema_get_field_value($info, $value);
}
$record->langcode = $langcode;
$record->default_langcode = intval($default_langcode == $langcode);
......
......@@ -328,6 +328,7 @@ public function getTranslation($langcode, $strict = TRUE) {
*/
public function getTranslationLanguages($include_default = TRUE) {
$translations = array();
$definitions = $this->getPropertyDefinitions();
// Build an array with the translation langcodes set as keys. Empty
// translations should not be included and must be skipped.
foreach ($this->getProperties() as $name => $property) {
......@@ -339,7 +340,7 @@ public function getTranslationLanguages($include_default = TRUE) {
foreach ($this->values[$name] as $langcode => $values) {
// If a value is there but the field object is empty, it has been
// unset, so we need to skip the field also.
if ($values && !(isset($this->fields[$name][$langcode]) && $this->fields[$name][$langcode]->isEmpty())) {
if ($values && !empty($definitions[$name]['translatable']) && !(isset($this->fields[$name][$langcode]) && $this->fields[$name][$langcode]->isEmpty())) {
$translations[$langcode] = TRUE;
}
}
......
......@@ -37,7 +37,14 @@ public static function getInfo() {
public function testBulkForm() {
$nodes = array();
for ($i = 0; $i < 10; $i++) {
$nodes[] = $this->drupalCreateNode(array('sticky' => FALSE));
// Ensure nodes are sorted in the same order they are inserted in the
// array.
$timestamp = REQUEST_TIME - $i;
$nodes[] = $this->drupalCreateNode(array(
'sticky' => FALSE,
'created' => $timestamp,
'changed' => $timestamp,
));
}
$this->drupalGet('test_bulk_form');
......
......@@ -56,7 +56,7 @@ display:
fields:
title:
id: title
table: node
table: node_field_data
field: title
label: ''
alter:
......@@ -124,7 +124,7 @@ display:
filters:
status:
value: '1'
table: node
table: node_field_data
field: status
id: status
expose:
......@@ -134,7 +134,7 @@ display:
sorts:
created: