From 4646e6fcadd26ed8ee32890229ce141d6ec5db59 Mon Sep 17 00:00:00 2001
From: phenaproxima <phenaproxima@205645.no-reply.drupal.org>
Date: Wed, 2 Feb 2022 22:43:41 +0000
Subject: [PATCH] Issue #3261847 by phenaproxima, tedbow: Add helpful methods
 to compute the difference between two ComposerUtility objects

---
 package_manager/src/ComposerUtility.php       | 64 ++++++++++++-------
 .../packages_comparison/active/composer.json  |  1 +
 .../active/vendor/composer/installed.json     | 16 +++++
 .../packages_comparison/stage/composer.json   |  1 +
 .../stage/vendor/composer/installed.json      | 16 +++++
 .../tests/src/Kernel/ComposerUtilityTest.php  | 19 ++++++
 src/Validator/StagedProjectsValidator.php     | 47 ++++++++------
 .../StagedProjectsValidatorTest.php           |  4 +-
 8 files changed, 123 insertions(+), 45 deletions(-)
 create mode 100644 package_manager/tests/fixtures/packages_comparison/active/composer.json
 create mode 100644 package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
 create mode 100644 package_manager/tests/fixtures/packages_comparison/stage/composer.json
 create mode 100644 package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json

diff --git a/package_manager/src/ComposerUtility.php b/package_manager/src/ComposerUtility.php
index a518bcfee8..32507915c1 100644
--- a/package_manager/src/ComposerUtility.php
+++ b/package_manager/src/ComposerUtility.php
@@ -6,6 +6,7 @@ use Composer\Composer;
 use Composer\Factory;
 use Composer\IO\NullIO;
 use Composer\Package\PackageInterface;
+use Composer\Semver\Comparator;
 use Drupal\Component\Serialization\Json;
 
 /**
@@ -133,35 +134,13 @@ class ComposerUtility {
     return array_values($core_packages);
   }
 
-  /**
-   * Returns all Drupal extension packages in the lock file.
-   *
-   * The following package types are considered Drupal extension packages:
-   * drupal-module, drupal-theme, drupal-custom-module, and drupal-custom-theme.
-   *
-   * @return \Composer\Package\PackageInterface[]
-   *   All Drupal extension packages in the lock file, keyed by name.
-   */
-  public function getDrupalExtensionPackages(): array {
-    $filter = function (PackageInterface $package): bool {
-      $drupal_package_types = [
-        'drupal-module',
-        'drupal-theme',
-        'drupal-custom-module',
-        'drupal-custom-theme',
-      ];
-      return in_array($package->getType(), $drupal_package_types, TRUE);
-    };
-    return array_filter($this->getInstalledPackages(), $filter);
-  }
-
   /**
    * Returns information on all installed packages.
    *
    * @return \Composer\Package\PackageInterface[]
    *   All installed packages, keyed by name.
    */
-  protected function getInstalledPackages(): array {
+  public function getInstalledPackages(): array {
     $installed_packages = $this->getComposer()
       ->getRepositoryManager()
       ->getLocalRepository()
@@ -175,4 +154,43 @@ class ComposerUtility {
     return $packages;
   }
 
+  /**
+   * Returns the packages that are in the current project, but not in another.
+   *
+   * @param self $other
+   *   A Composer utility wrapper around a different directory.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   The packages which are installed in the current project, but not in the
+   *   other one, keyed by name.
+   */
+  public function getPackagesNotIn(self $other): array {
+    return array_diff_key($this->getInstalledPackages(), $other->getInstalledPackages());
+  }
+
+  /**
+   * Returns the packages which have a different version in another project.
+   *
+   * This compares the current project with another one, and returns packages
+   * which are present in both, but in different versions.
+   *
+   * @param self $other
+   *   A Composer utility wrapper around a different directory.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   The packages which are present in both the current project and the other
+   *   one, but in different versions, keyed by name.
+   */
+  public function getPackagesWithDifferentVersionsIn(self $other): array {
+    $theirs = $other->getInstalledPackages();
+
+    // Only compare packages that are both here and there.
+    $packages = array_intersect_key($this->getInstalledPackages(), $theirs);
+
+    $filter = function (PackageInterface $package, string $name) use ($theirs): bool {
+      return Comparator::notEqualTo($package->getVersion(), $theirs[$name]->getVersion());
+    };
+    return array_filter($packages, $filter, ARRAY_FILTER_USE_BOTH);
+  }
+
 }
diff --git a/package_manager/tests/fixtures/packages_comparison/active/composer.json b/package_manager/tests/fixtures/packages_comparison/active/composer.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/package_manager/tests/fixtures/packages_comparison/active/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json b/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
new file mode 100644
index 0000000000..2152ef82fb
--- /dev/null
+++ b/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
@@ -0,0 +1,16 @@
+{
+  "packages": [
+    {
+      "name": "drupal/existing",
+      "version": "1.0.0"
+    },
+    {
+      "name": "drupal/updated",
+      "version": "1.0.0"
+    },
+    {
+      "name": "drupal/removed",
+      "version": "1.0.0"
+    }
+  ]
+}
diff --git a/package_manager/tests/fixtures/packages_comparison/stage/composer.json b/package_manager/tests/fixtures/packages_comparison/stage/composer.json
new file mode 100644
index 0000000000..0967ef424b
--- /dev/null
+++ b/package_manager/tests/fixtures/packages_comparison/stage/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json b/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json
new file mode 100644
index 0000000000..562b5f5601
--- /dev/null
+++ b/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json
@@ -0,0 +1,16 @@
+{
+  "packages": [
+    {
+      "name": "drupal/existing",
+      "version": "1.0.0"
+    },
+    {
+      "name": "drupal/updated",
+      "version": "1.1.0"
+    },
+    {
+      "name": "drupal/added",
+      "version": "1.0.0"
+    }
+  ]
+}
diff --git a/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/package_manager/tests/src/Kernel/ComposerUtilityTest.php
index 2356c46209..3ac38a4870 100644
--- a/package_manager/tests/src/Kernel/ComposerUtilityTest.php
+++ b/package_manager/tests/src/Kernel/ComposerUtilityTest.php
@@ -72,4 +72,23 @@ class ComposerUtilityTest extends KernelTestBase {
     $this->assertSame($expected_packages, $packages);
   }
 
+  /**
+   * @covers ::getPackagesNotIn
+   * @covers ::getPackagesWithDifferentVersionsIn
+   */
+  public function testPackageComparison(): void {
+    $fixture_dir = __DIR__ . '/../../fixtures/packages_comparison';
+    $active = ComposerUtility::createForDirectory($fixture_dir . '/active');
+    $staged = ComposerUtility::createForDirectory($fixture_dir . '/stage');
+
+    $added = $staged->getPackagesNotIn($active);
+    $this->assertSame(['drupal/added'], array_keys($added));
+
+    $removed = $active->getPackagesNotIn($staged);
+    $this->assertSame(['drupal/removed'], array_keys($removed));
+
+    $updated = $active->getPackagesWithDifferentVersionsIn($staged);
+    $this->assertSame(['drupal/updated'], array_keys($updated));
+  }
+
 }
diff --git a/src/Validator/StagedProjectsValidator.php b/src/Validator/StagedProjectsValidator.php
index c3ad1e878b..eec9e8f9fb 100644
--- a/src/Validator/StagedProjectsValidator.php
+++ b/src/Validator/StagedProjectsValidator.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\automatic_updates\Validator;
 
+use Composer\Package\PackageInterface;
 use Drupal\automatic_updates\Updater;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
@@ -39,8 +40,8 @@ final class StagedProjectsValidator implements EventSubscriberInterface {
     }
 
     try {
-      $active_packages = $stage->getActiveComposer()->getDrupalExtensionPackages();
-      $staged_packages = $stage->getStageComposer()->getDrupalExtensionPackages();
+      $active = $stage->getActiveComposer();
+      $stage = $stage->getStageComposer();
     }
     catch (\Throwable $e) {
       $event->addError([
@@ -55,8 +56,15 @@ final class StagedProjectsValidator implements EventSubscriberInterface {
       'drupal-theme' => $this->t('theme'),
       'drupal-custom-theme' => $this->t('custom theme'),
     ];
+    $filter = function (PackageInterface $package) use ($type_map): bool {
+      return array_key_exists($package->getType(), $type_map);
+    };
+    $new_packages = $stage->getPackagesNotIn($active);
+    $removed_packages = $active->getPackagesNotIn($stage);
+    $updated_packages = $active->getPackagesWithDifferentVersionsIn($stage);
+
     // Check if any new Drupal projects were installed.
-    if ($new_packages = array_diff_key($staged_packages, $active_packages)) {
+    if ($new_packages = array_filter($new_packages, $filter)) {
       $new_packages_messages = [];
 
       foreach ($new_packages as $new_package) {
@@ -77,7 +85,7 @@ final class StagedProjectsValidator implements EventSubscriberInterface {
     }
 
     // Check if any Drupal projects were removed.
-    if ($removed_packages = array_diff_key($active_packages, $staged_packages)) {
+    if ($removed_packages = array_filter($removed_packages, $filter)) {
       $removed_packages_messages = [];
       foreach ($removed_packages as $removed_package) {
         $removed_packages_messages[] = $this->t(
@@ -96,22 +104,21 @@ final class StagedProjectsValidator implements EventSubscriberInterface {
       $event->addError($removed_packages_messages, $removed_packages_summary);
     }
 
-    // Get all the packages that are neither newly installed or removed to
-    // check if their version numbers changed.
-    if ($pre_existing_packages = array_diff_key($staged_packages, $removed_packages, $new_packages)) {
-      foreach ($pre_existing_packages as $package_name => $staged_existing_package) {
-        $active_package = $active_packages[$package_name];
-        if ($staged_existing_package->getVersion() !== $active_package->getVersion()) {
-          $version_change_messages[] = $this->t(
-            "@type '@name' from @active_version to  @staged_version.",
-            [
-              '@type' => $type_map[$active_package->getType()],
-              '@name' => $active_package->getName(),
-              '@staged_version' => $staged_existing_package->getPrettyVersion(),
-              '@active_version' => $active_package->getPrettyVersion(),
-            ]
-          );
-        }
+    // Check if any Drupal projects were neither installed or removed, but had
+    // their version numbers changed.
+    if ($updated_packages = array_filter($updated_packages, $filter)) {
+      $staged_packages = $stage->getInstalledPackages();
+
+      foreach ($updated_packages as $name => $updated_package) {
+        $version_change_messages[] = $this->t(
+          "@type '@name' from @active_version to @staged_version.",
+          [
+            '@type' => $type_map[$updated_package->getType()],
+            '@name' => $updated_package->getName(),
+            '@staged_version' => $staged_packages[$name]->getPrettyVersion(),
+            '@active_version' => $updated_package->getPrettyVersion(),
+          ]
+        );
       }
       if (!empty($version_change_messages)) {
         $version_change_summary = $this->formatPlural(
diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index 3799a239b9..1298f45791 100644
--- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -199,8 +199,8 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
         "$fixtures_folder/version_changed",
         'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.',
         [
-          "module 'drupal/test_module' from 1.3.0 to  1.3.1.",
-          "module 'drupal/dev-test_module' from 1.3.0 to  1.3.1.",
+          "module 'drupal/test_module' from 1.3.0 to 1.3.1.",
+          "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.",
         ],
       ],
     ];
-- 
GitLab