Commit a0f3a950 authored by MegaChriz's avatar MegaChriz Committed by MegaChriz

Issue #2989279 by MegaChriz, jamesdixon, tanubansal, govind.maloo: Added a...

Issue #2989279 by MegaChriz, jamesdixon, tanubansal, govind.maloo: Added a target to entity ID (and added support for other read-only fields too).
parent 84741aac
......@@ -32,14 +32,17 @@ function feeds_help($route_name, RouteMatchInterface $route_match) {
],
];
return new FormattableMarkup('<p>@help1</p><p>@help2</p>', [
return new FormattableMarkup('<p>@help1</p><p>@help2</p><p>@help3</p>', [
'@help1' => t('Define which elements of a single item of a feed (= %sources_label) map to which content pieces in Drupal (= %targets_label). To avoid importing duplicates, make sure that at least one definition has an %unique_target_label. An unique target means that a value for a target can only occur once. For example, only one item with the URL %example_url can exist.', [
'%sources_label' => t('Sources'),
'%targets_label' => t('Targets'),
'%unique_target_label' => t('Unique target'),
'%example_url' => 'http://example.com/content/1',
]),
'@help2' => t('See the @doc_link for more information.', [
'@help2' => t('On %read_only_label targets a value can only be set the first time.', [
'%read_only_label' => t('Read only'),
]),
'@help3' => t('See the @doc_link for more information.', [
'@doc_link' => \Drupal::service('renderer')->renderRoot($doc_link),
]),
]);
......
......@@ -21,6 +21,8 @@ use Drupal\feeds\Exception\ValidationException;
use Drupal\feeds\FeedInterface;
use Drupal\feeds\Feeds\Item\ItemInterface;
use Drupal\feeds\Feeds\State\CleanStateInterface;
use Drupal\feeds\FieldTargetDefinition;
use Drupal\feeds\Plugin\Type\MappingPluginFormInterface;
use Drupal\feeds\Plugin\Type\Processor\EntityProcessorInterface;
use Drupal\feeds\Plugin\Type\Target\TargetInterface;
use Drupal\feeds\Plugin\Type\Target\TranslatableTargetInterface;
......@@ -35,7 +37,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
*
* Creates entities from feed items.
*/
abstract class EntityProcessorBase extends ProcessorBase implements EntityProcessorInterface, ContainerFactoryPluginInterface {
abstract class EntityProcessorBase extends ProcessorBase implements EntityProcessorInterface, ContainerFactoryPluginInterface, MappingPluginFormInterface {
/**
* The entity type manager.
......@@ -502,10 +504,39 @@ abstract class EntityProcessorBase extends ProcessorBase implements EntityProces
return $entity->getTranslation($langcode);
}
/**
* Checks if the entity exists already.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return bool
* True if the entity already exists, false otherwise.
*/
protected function entityExists(EntityInterface $entity) {
if ($entity->id()) {
$result = $this->storageController
->getQuery()
->condition($this->entityType->getKey('id'), $entity->id())
->execute();
return !empty($result);
}
return FALSE;
}
/**
* {@inheritdoc}
*/
protected function entityValidate(EntityInterface $entity) {
// Check if an entity with the same ID already exists if the given entity is
// new.
if ($entity->isNew() && $this->entityExists($entity)) {
throw new ValidationException($this->t('An entity with ID %id already exists.', [
'%id' => $entity->id(),
]));
}
$violations = $entity->validate();
if (!count($violations)) {
return;
......@@ -588,8 +619,18 @@ abstract class EntityProcessorBase extends ProcessorBase implements EntityProces
// If the uid was mapped directly, rather than by email or username, it
// could be invalid.
if (!$account = $entity->getOwner()) {
throw new EntityAccessException($this->t('Invalid user mapped to %label.', ['%label' => $entity->label()]));
$account = $entity->getOwner();
if (!$account) {
$owner_id = $entity->getOwnerId();
if ($owner_id == 0) {
// We don't check access for anonymous users.
return;
}
throw new EntityAccessException($this->t('Invalid user with ID %uid mapped to %label.', [
'%uid' => $owner_id,
'%label' => $entity->label(),
]));
}
// We don't check access for anonymous users.
......@@ -854,6 +895,85 @@ abstract class EntityProcessorBase extends ProcessorBase implements EntityProces
return $form;
}
/**
* {@inheritdoc}
*/
public function mappingFormAlter(array &$form, FormStateInterface $form_state) {
$added_target = $form_state->getValue('add_target');
if (!$added_target) {
// No target was added this time around. Abort.
return;
}
// When adding a mapping target to entity ID, tick 'unique' by default.
$id_key = $this->entityType->getKey('id');
$mappings = $this->feedType->getMappings();
$last_delta = array_keys($mappings)[count($mappings) - 1];
$mapping = end($mappings);
if ($mapping['target'] != $added_target) {
return;
}
$target_definition = $this->feedType->getTargetPlugin($last_delta)
->getTargetDefinition();
if (!$target_definition instanceof FieldTargetDefinition) {
return;
}
/** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
$field_definition = $target_definition->getFieldDefinition();
if ($field_definition->getName() != $id_key) {
return;
}
// We made it! Set property as unique.
$form['mappings'][$last_delta]['unique'][$field_definition->getMainPropertyName()]['#default_value'] = TRUE;
}
/**
* {@inheritdoc}
*/
public function mappingFormValidate(array &$form, FormStateInterface $form_state) {
// Display a warning when mapping to entity ID and having that one not set
// as unique.
$id_key = $this->entityType->getKey('id');
foreach ($this->feedType->getMappings() as $delta => $mapping) {
$target_definition = $this->feedType->getTargetPlugin($delta)
->getTargetDefinition();
if (!$target_definition instanceof FieldTargetDefinition) {
continue;
}
/** @var \Drupal\Core\Field\FieldDefinitionInterface $field_definition */
$field_definition = $target_definition->getFieldDefinition();
if ($field_definition->getName() != $id_key) {
continue;
}
$is_unique = $form_state->getValue([
'mappings',
$delta,
'unique',
$field_definition->getMainPropertyName(),
]);
if (!$is_unique) {
// Entity ID not set as unique. Display warning.
$this->messenger()->addWarning($this->t('When mapping to the entity ID (@name), it is recommended to set it as unique.', [
'@name' => $target_definition->getLabel(),
]));
}
}
}
/**
* {@inheritdoc}
*/
public function mappingFormSubmit(array &$form, FormStateInterface $form_state) {
// The entity processor doesn't have to do anything when mappings are saved.
}
/**
* {@inheritdoc}
*/
......@@ -954,6 +1074,12 @@ abstract class EntityProcessorBase extends ProcessorBase implements EntityProces
// Set target values.
foreach ($mappings as $delta => $mapping) {
$plugin = $this->feedType->getTargetPlugin($delta);
// Skip immutable targets for which the entity already has a value.
if (!$plugin->isMutable() && !$plugin->isEmpty($feed, $entity, $mapping['target'])) {
continue;
}
if (isset($field_values[$delta])) {
$plugin->setTarget($feed, $entity, $mapping['target'], $field_values[$delta]);
}
......@@ -973,6 +1099,11 @@ abstract class EntityProcessorBase extends ProcessorBase implements EntityProces
* The property to clear on the entity.
*/
protected function clearTarget(EntityInterface $entity, TargetInterface $target, $target_name) {
if (!$target->isMutable()) {
// Don't clear immutable targets.
return;
}
$entity_target = $entity;
// If the target implements TranslatableTargetInterface and has a language
......
......@@ -3,9 +3,7 @@
namespace Drupal\feeds\Feeds\Target;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\feeds\FeedTypeInterface;
use Drupal\feeds\FieldTargetDefinition;
use Drupal\feeds\Plugin\Type\Processor\EntityProcessorInterface;
use Drupal\feeds\Plugin\Type\Target\FieldTargetBase;
/**
......@@ -18,34 +16,6 @@ use Drupal\feeds\Plugin\Type\Target\FieldTargetBase;
*/
class Path extends FieldTargetBase {
/**
* {@inheritdoc}
*
* This method is overridden to make the target available even when the
* pathauto module is enabled.
*/
public static function targets(array &$targets, FeedTypeInterface $feed_type, array $definition) {
$processor = $feed_type->getProcessor();
if (!$processor instanceof EntityProcessorInterface) {
return $targets;
}
$field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions($processor->entityType(), $processor->bundle());
foreach ($field_definitions as $id => $field_definition) {
if ($id === $processor->bundleKey()) {
continue;
}
if (in_array($field_definition->getType(), $definition['field_types'])) {
if ($target = static::prepareTarget($field_definition)) {
$target->setPluginId($definition['id']);
$targets[$id] = $target;
}
}
}
}
/**
* {@inheritdoc}
*/
......@@ -80,4 +50,13 @@ class Path extends FieldTargetBase {
}
}
/**
* {@inheritdoc}
*/
public function isMutable() {
// The path field is set to "computed", which evaluates to "read-only".
// Ignore the fact that paths are read-only and mark it as mutable.
return TRUE;
}
}
......@@ -315,6 +315,14 @@ class MappingForm extends FormBase {
];
}
}
elseif ($plugin instanceof ConfigurableTargetInterface) {
$summary = $this->buildSummary($plugin);
if (!empty($summary)) {
$row['settings'] = [
'#parents' => ['config_summary', $delta],
] + $this->buildSummary($plugin);
}
}
$mappings = $this->feedType->getMappings();
......
......@@ -47,7 +47,7 @@ abstract class FieldTargetBase extends TargetBase implements ConfigurableTargetI
$field_definitions = \Drupal::service('entity_field.manager')->getFieldDefinitions($processor->entityType(), $processor->bundle());
foreach ($field_definitions as $id => $field_definition) {
if ($field_definition->isReadOnly() || $id === $processor->bundleKey()) {
if ($id === $processor->bundleKey()) {
continue;
}
if (in_array($field_definition->getType(), $definition['field_types'])) {
......@@ -99,6 +99,20 @@ abstract class FieldTargetBase extends TargetBase implements ConfigurableTargetI
}
}
/**
* {@inheritdoc}
*/
public function isMutable() {
return !$this->targetDefinition->getFieldDefinition()->isReadOnly();
}
/**
* {@inheritdoc}
*/
public function isEmpty(FeedInterface $feed, EntityInterface $entity, $field_name) {
return $entity->get($field_name)->isEmpty();
}
/**
* Get entity, or entity translation to set the map.
*
......@@ -302,12 +316,18 @@ abstract class FieldTargetBase extends TargetBase implements ConfigurableTargetI
*/
public function getSummary() {
$summary = [];
if (!$this->isMutable()) {
$summary[] = $this->t('Read only');
}
if ($this->isTargetTranslatable()) {
$language = $this->getLanguageManager()->getLanguage($this->configuration['language']);
if ($language instanceof LanguageInterface) {
$summary[] = $this->t('Language: @language', ['@language' => $language->getName()]);
}
}
return $summary;
}
......
......@@ -53,6 +53,13 @@ abstract class TargetBase extends ConfigurablePluginBase implements TargetInterf
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public function getTargetDefinition() {
return $this->targetDefinition;
}
/**
* {@inheritdoc}
*/
......
......@@ -38,4 +38,35 @@ interface TargetInterface extends DependentWithRemovalPluginInterface {
*/
public function setTarget(FeedInterface $feed, EntityInterface $entity, $target, array $values);
/**
* Returns the target's definition.
*
* @return \Drupal\feeds\TargetDefinitionInterface
* The definition for this target.
*/
public function getTargetDefinition();
/**
* Returns if the target is mutable.
*
* @return bool
* True if the target is mutable. False otherwise.
*/
public function isMutable();
/**
* Returns if the value for the target is empty.
*
* @param \Drupal\feeds\FeedInterface $feed
* The feed object.
* @param \Drupal\Core\Entity\EntityInterface $entity
* The target object.
* @param string $target
* The name of the target to set.
*
* @return bool
* True if the value on the entity is empty. False otherwise.
*/
public function isEmpty(FeedInterface $feed, EntityInterface $entity, $target);
}
......@@ -11,11 +11,14 @@ use Drupal\Tests\feeds\Functional\FeedsBrowserTestBase;
class MappingFormTest extends FeedsBrowserTestBase {
/**
* Modules to enable.
*
* @var array
* {@inheritdoc}
*/
public static $modules = ['feeds_test_plugin'];
public static $modules = [
'feeds',
'node',
'user',
'feeds_test_plugin',
];
/**
* Tests that custom source names are unique.
......@@ -89,4 +92,35 @@ class MappingFormTest extends FeedsBrowserTestBase {
$this->assertEquals('', $config['dummy']);
}
/**
* Tests mapping to entity ID target without setting it as unique.
*
* When adding a target to entity ID and set it is not as unique, a warning
* should get displayed, recommending to set the target as unique.
*/
public function testMappingToEntityIdWarning() {
$feed_type = $this->createFeedType();
// Add mapping to node ID.
$edit = [
'add_target' => 'nid',
];
$this->drupalPostForm('/admin/structure/feeds/manage/' . $feed_type->id() . '/mapping', $edit, 'Save');
// Now untick "unique".
$edit = [
'mappings[2][unique][value]' => 0,
];
$this->drupalPostForm(NULL, $edit, 'Save');
// Assert that a message is being displayed.
$this->assertSession()->pageTextContains('When mapping to the entity ID (ID), it is recommended to set it as unique.');
// But ensure "unique" can get unticked for entity ID targets anyway.
// Because this could perhaps be useful in advanced use cases.
$feed_type = $this->reloadEntity($feed_type);
$mapping = $feed_type->getMappings()[2];
$this->assertTrue(empty($mapping['unique']['value']));
}
}
......@@ -175,4 +175,34 @@ class MappingFormTest extends FeedsJavascriptTestBase {
$assert_session->pageTextContains('The machine-readable name is already in use. It must be unique.');
}
/**
* Tests that a mapper to entity ID gets set as unique by default.
*/
public function testEntityIdTargetIsUniqueByDefault() {
$feed_type = $this->createFeedType();
// Go to the mapping form.
$this->drupalGet('/admin/structure/feeds/manage/' . $feed_type->id() . '/mapping');
$session = $this->getSession();
$assert_session = $this->assertSession();
$page = $session->getPage();
$assert_session->fieldExists('add_target');
$page->selectFieldOption('add_target', 'nid');
$assert_session->assertWaitOnAjaxRequest();
// Assert that "unique" is ticked by default.
$assert_session->fieldValueEquals('mappings[2][unique][value]', '1');
// Try to save the form.
$this->submitForm([], 'Save');
$assert_session->pageTextNotContains('When mapping to the entity ID (ID), it is recommended to set it as unique.');
// Assert that the new target is marked as "unique".
$feed_type = $this->reloadEntity($feed_type);
$mapping = $feed_type->getMappings()[2];
$this->assertEquals(1, $mapping['unique']['value']);
}
}
<?php
namespace Drupal\Tests\feeds\Kernel;
use Drupal\feeds\Plugin\Type\Processor\ProcessorInterface;
use Drupal\node\Entity\Node;
/**
* Tests for inserting and updating entity ID's.
*
* @group feeds
*/
class EntityIdTest extends FeedsKernelTestBase {
/**
* Tests creating a node where the source dictates the node ID.
*/
public function testInsertNodeId() {
$feed_type = $this->createFeedTypeForCsv([
'title' => 'title',
'beta' => 'beta',
], [
'mappings' => [
[
'target' => 'title',
'map' => ['value' => 'title'],
],
[
'target' => 'nid',
'map' => ['value' => 'beta'],
],
],
]);
// Import data.
$feed = $this->createFeed($feed_type->id(), [
'source' => $this->resourcesPath() . '/csv/content.csv',
]);
$feed->import();
$this->assertNodeCount(2);
// Check the imported values.
$node = Node::load(42);
$this->assertEquals('Lorem ipsum', $node->title->value);
$node = Node::load(32);
$this->assertEquals('Ut wisi enim ad minim veniam', $node->title->value);
// Ensure that an other import doesn't result into SQL errors.
$feed->import();
// Ensure that there are no SQL warnings.
$messages = \Drupal::messenger()->all();
foreach ($messages['warning'] as $warning) {
$this->assertStringNotContainsString('SQLSTATE', $warning);
}
}
/**
* Tests updating an existing node using node ID.
*/
public function testUpdateByNodeId() {
$feed_type = $this->createFeedTypeForCsv([
'title' => 'title',
'beta' => 'beta',
], [
'processor_configuration' => [
'update_existing' => ProcessorInterface::UPDATE_EXISTING,
'values' => [
'type' => 'article',
],
],
'mappings' => [
[
'target' => 'title',
'map' => ['value' => 'title'],
],
[
'target' => 'nid',
'map' => ['value' => 'beta'],
'unique' => ['value' => TRUE],
],
],
]);
// Create a node with ID 42.
$node = Node::create([
'nid' => 42,
'title' => 'Foo',
'type' => 'article',
]);
$node->save();
// Import data.
$feed = $this->createFeed($feed_type->id(), [
'source' => $this->resourcesPath() . '/csv/content.csv',
]);
$feed->import();
$this->assertNodeCount(2);
// Ensure that the title got updated.
$node = $this->reloadEntity($node);
$this->assertEquals('Lorem ipsum', $node->title->value);
}
/**
* Tests that node ID's don't change and that existing nodes are not hijacked.
*/
public function testNoNodeIdChange() {
// Create two existing articles.
$node1 = Node::create([
'title' => 'Lorem ipsum',
'type' => 'article',
]);
$node1->save();
// Create an existing article with ID 32.
$node32 = Node::create([
'nid' => 32,
'title' => 'Foo',
'type' => 'article',
]);
$node32->save();
// Create a feed type, update by title.
$feed_type = $this->createFeedTypeForCsv([
'title' => 'title',
'beta' => 'beta',
], [
'processor_configuration' => [
'update_existing' => ProcessorInterface::UPDATE_EXISTING,
'values' => [
'type' => 'article',
],
],
'mappings' => [
[
'target' => 'title',
'map' => ['value' => 'title'],
'unique' => ['value' => TRUE],
],
[
'target' => 'nid',
'map' => ['value' => 'beta'],
],
],
]);
// Import data.
$feed = $this->createFeed($feed_type->id(), [
'source' => $this->resourcesPath() . '/csv/content.csv',
]);
$feed->import();
$this->assertNodeCount(2);
// Ensure that node 42 doesn't exist.
$this->assertNull(Node::load(42));
// Ensure that node 32 is still called 'Foo'.
$node32 = $this->reloadEntity($node32);
$this->assertEquals('Foo', $node32->title->value);
// Ensure that there are no SQL warnings.
$messages = \Drupal::messenger()->all();
foreach ($messages['warning'] as $warning) {
$this->assertStringNotContainsString('SQLSTATE', $warning);
}