Verified Commit f665255f authored by Alex Pott's avatar Alex Pott
Browse files

Issue #3442022 by phenaproxima, alexpott, larowlan: Trigger entity validation...

Issue #3442022 by phenaproxima, alexpott, larowlan: Trigger entity validation in \Drupal\Core\DefaultContent\Importer::importContent()

(cherry picked from commit 1736da1fcc50cc12fdd4ce2592cc0772cc30c9e4)
parent 74325fd8
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -43,8 +43,8 @@ public function __construct(string $path) {
    foreach ($finder as $file) {
      /** @var array{_meta: array{uuid: string|null, depends: array<string, string>|null}} $decoded */
      $decoded = Yaml::decode($file->getContents());
      $uuid = $decoded['_meta']['uuid'] ?? throw new ImportException($file->getPathname() . ' does not have a UUID.');
      $decoded['_meta']['path'] = $file->getPath();
      $decoded['_meta']['path'] = $file->getPathname();
      $uuid = $decoded['_meta']['uuid'] ?? throw new ImportException($decoded['_meta']['path'] . ' does not have a UUID.');
      $files[$uuid] = $decoded;

      // For the graph to work correctly, every entity must be mentioned in it.
+27 −15
Original line number Diff line number Diff line
@@ -32,10 +32,10 @@ final class Importer implements LoggerAwareInterface {
  /**
   * The dependencies of the currently importing entity, if any.
   *
   * The keys are the UUIDs of the dependencies, and the values are their entity
   * type.
   * The keys are the UUIDs of the dependencies, and the values are arrays with
   * two members: the entity type ID of the dependency, and the UUID to load.
   *
   * @var string[]|null
   * @var array<string, string[]>|null
   */
  private ?array $dependencies = NULL;

@@ -106,7 +106,11 @@ public function importContent(Finder $content, Existing $existing = Existing::Er
        // If a file exists in the same folder, copy it to the designated
        // target URI.
        if ($entity instanceof FileInterface) {
          $this->copyFileAssociatedWithEntity($path, $entity);
          $this->copyFileAssociatedWithEntity(dirname($path), $entity);
        }
        $violations = $entity->validate();
        if (count($violations) > 0) {
          throw new InvalidEntityException($violations, $path);
        }
        $entity->save();
      }
@@ -116,7 +120,7 @@ public function importContent(Finder $content, Existing $existing = Existing::Er
    }
  }

  private function copyFileAssociatedWithEntity(string $path, FileInterface $entity): void {
  private function copyFileAssociatedWithEntity(string $path, FileInterface &$entity): void {
    $destination = $entity->getFileUri();
    assert(is_string($destination));

@@ -130,23 +134,28 @@ private function copyFileAssociatedWithEntity(string $path, FileInterface $entit
      return;
    }

    $copy_file = TRUE;
    if (file_exists($destination)) {
      $source_hash = hash_file('sha256', $source);
      assert(is_string($source_hash));
      $destination_hash = hash_file('sha256', $destination);
      assert(is_string($destination_hash));

      // If we already have the file, we don't need to do anything else.
      if (hash_equals($source_hash, $destination_hash)) {
        return;
      if (hash_equals($source_hash, $destination_hash) && $this->entityTypeManager->getStorage('file')->loadByProperties(['uri' => $destination]) === []) {
        // If the file hashes match and the file is not already a managed file
        // then do not copy a new version to the file system. This prevents
        // re-installs during development from creating unnecessary duplicates.
        $copy_file = FALSE;
      }
    }

    $target_directory = dirname($destination);
    $this->fileSystem->prepareDirectory($target_directory, FileSystemInterface::CREATE_DIRECTORY);
    if ($copy_file) {
      $uri = $this->fileSystem->copy($source, $destination);
      $entity->setFileUri($uri);
    }
  }

  /**
   * Converts an array of content entity data to a content entity object.
@@ -168,16 +177,19 @@ private function toEntity(array $data): ContentEntityInterface {
      throw new ImportException('The uuid metadata must be specified.');
    }

    ['entity_type' => $entity_type] = $data['_meta'];
    assert(is_string($entity_type));

    $is_root = FALSE;
    // @see ::loadEntityDependency()
    if ($this->dependencies === NULL && !empty($data['_meta']['depends'])) {
      $is_root = TRUE;
      $this->dependencies = $data['_meta']['depends'];
      foreach ($data['_meta']['depends'] as $uuid => $entity_type) {
        assert(is_string($uuid));
        assert(is_string($entity_type));
        $this->dependencies[$uuid] = [$entity_type, $uuid];
      }
    }

    ['entity_type' => $entity_type] = $data['_meta'];
    assert(is_string($entity_type));
    /** @var \Drupal\Core\Entity\EntityTypeInterface $entity_type */
    $entity_type = $this->entityTypeManager->getDefinition($entity_type);

@@ -307,7 +319,7 @@ private function getCustomSerializedPropertyNames(FieldItemInterface $field_item
   */
  private function loadEntityDependency(string $target_uuid): ?ContentEntityInterface {
    if ($this->dependencies && array_key_exists($target_uuid, $this->dependencies)) {
      $entity = $this->entityRepository->loadEntityByUuid($this->dependencies[$target_uuid], $target_uuid);
      $entity = $this->entityRepository->loadEntityByUuid(...$this->dependencies[$target_uuid]);
      assert($entity instanceof ContentEntityInterface || $entity === NULL);
      return $entity;
    }
+27 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Core\DefaultContent;

use Drupal\Core\Entity\EntityConstraintViolationListInterface;
use Symfony\Component\Validator\ConstraintViolationInterface;

/**
 * Thrown if an entity being imported has validation errors.
 *
 * @internal
 *   This API is experimental.
 */
final class InvalidEntityException extends \RuntimeException {

  public function __construct(public readonly EntityConstraintViolationListInterface $violations, public readonly string $filePath) {
    $messages = [];

    foreach ($violations as $violation) {
      assert($violation instanceof ConstraintViolationInterface);
      $messages[] = $violation->getPropertyPath() . '=' . $violation->getMessage();
    }
    // Example: "/path/to/file.yml: field_a=Violation 1., field_b=Violation 2.".
    parent::__construct("$filePath: " . implode('||', $messages));
  }

}
+39 −9
Original line number Diff line number Diff line
@@ -7,11 +7,14 @@
use ColinODell\PsrTestLogger\TestLogger;
use Drupal\block_content\BlockContentInterface;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\Component\Serialization\Yaml;
use Drupal\Core\DefaultContent\Existing;
use Drupal\Core\DefaultContent\Finder;
use Drupal\Core\DefaultContent\Importer;
use Drupal\Core\DefaultContent\ImportException;
use Drupal\Core\DefaultContent\InvalidEntityException;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\File\FileExists;
use Drupal\Core\Url;
use Drupal\field\Entity\FieldConfig;
use Drupal\field\Entity\FieldStorageConfig;
@@ -103,6 +106,7 @@ protected function setUp(): void {
      ->save();

    $this->contentDir = $this->getDrupalRoot() . '/core/tests/fixtures/default_content';
    \Drupal::service('file_system')->copy($this->contentDir . '/file/druplicon_copy.png', $this->publicFilesDirectory . '/druplicon_copy.png', FileExists::Error);
  }

  /**
@@ -154,6 +158,25 @@ public function testDirectContentImport(): void {
    $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::WARNING));
  }

  /**
   * Tests that the importer validates entities before saving them.
   */
  public function testEntityValidationIsTriggered(): void {
    $dir = uniqid('public://');
    mkdir($dir);

    /** @var string $data */
    $data = file_get_contents($this->contentDir . '/node/2d3581c3-92c7-4600-8991-a0d4b3741198.yml');
    $data = Yaml::decode($data);
    /** @var array{default: array{sticky: array<int, array{value: mixed}>}} $data */
    $data['default']['sticky'][0]['value'] = 'not a boolean!';
    file_put_contents($dir . '/invalid.yml', Yaml::encode($data));

    $this->expectException(InvalidEntityException::class);
    $this->expectExceptionMessage("$dir/invalid.yml: sticky.0.value=This value should be of the correct primitive type.");
    $this->container->get(Importer::class)->importContent(new Finder($dir));
  }

  /**
   * Asserts that the default content was imported as expected.
   */
@@ -188,12 +211,12 @@ private function assertContentWasImported(): void {
    $this->assertSame('druplicon.png', $file->getFilename());
    $this->assertSame('d8404562-efcc-40e3-869e-40132d53fe0b', $file->uuid());

    // Another file entity referencing an existing file should have the same
    // file URI -- in other words, it should have reused the existing file.
    $duplicate_file = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f');
    $this->assertInstanceOf(FileInterface::class, $duplicate_file);
    $this->assertSame('druplicon-duplicate.png', $duplicate_file->getFilename());
    $this->assertSame($file->getFileUri(), $duplicate_file->getFileUri());
    // Another file entity referencing an existing file but already in use by
    // another entity, should be imported.
    $same_file_different_entity = $entity_repository->loadEntityByUuid('file', '23a7f61f-1db3-407d-a6dd-eb4731995c9f');
    $this->assertInstanceOf(FileInterface::class, $same_file_different_entity);
    $this->assertSame('druplicon-duplicate.png', $same_file_different_entity->getFilename());
    $this->assertStringEndsWith('/druplicon_0.png', (string) $same_file_different_entity->getFileUri());

    // Another file entity that references a file with the same name as, but
    // different contents than, an existing file, should be imported and the
@@ -201,7 +224,14 @@ private function assertContentWasImported(): void {
    $different_file = $entity_repository->loadEntityByUuid('file', 'a6b79928-838f-44bd-a8f0-44c2fff9e4cc');
    $this->assertInstanceOf(FileInterface::class, $different_file);
    $this->assertSame('druplicon-different.png', $different_file->getFilename());
    $this->assertStringEndsWith('/druplicon_0.png', (string) $different_file->getFileUri());
    $this->assertStringEndsWith('/druplicon_1.png', (string) $different_file->getFileUri());

    // Another file entity referencing an existing file but one that is not in
    // use by another entity, should be imported but use the existing file.
    $different_file = $entity_repository->loadEntityByUuid('file', '7fb09f9f-ba5f-4db4-82ed-aa5ccf7d425d');
    $this->assertInstanceOf(FileInterface::class, $different_file);
    $this->assertSame('druplicon_copy.png', $different_file->getFilename());
    $this->assertStringEndsWith('/druplicon_copy.png', (string) $different_file->getFileUri());

    // Our node should have a menu link, and it should use the path alias we
    // included with the recipe.
@@ -214,7 +244,7 @@ private function assertContentWasImported(): void {
    $this->assertInstanceOf(BlockContentInterface::class, $block_content);
    $this->assertSame('basic', $block_content->bundle());
    $this->assertSame('Useful Info', $block_content->label());
    $this->assertSame("<p>I'd love to put some useful info here.</p>", $block_content->body->value);
    $this->assertSame("I'd love to put some useful info here.", $block_content->body->value);

    // A node with a non-existent owner should be reassigned to the current
    // user.
@@ -227,7 +257,7 @@ private function assertContentWasImported(): void {
    $this->assertInstanceOf(NodeInterface::class, $node);
    $translation = $node->getTranslation('fr');
    $this->assertSame('Perdu en traduction', $translation->label());
    $this->assertSame("<p>Içi c'est la version français.</p>", $translation->body->value);
    $this->assertSame("Içi c'est la version français.", $translation->body->value);
  }

}
+2 −2
Original line number Diff line number Diff line
@@ -19,6 +19,6 @@ default:
      value: true
  body:
    -
      value: "<p>I'd love to put some useful info here.</p>"
      format: basic_html
      value: "I'd love to put some useful info here."
      format: plain_text
      summary: ''
Loading