From 6653fab37958feaa4de309fd8e7130fd4b4f137d Mon Sep 17 00:00:00 2001
From: catch <6915-catch@users.noreply.drupalcode.org>
Date: Thu, 23 Jan 2025 16:42:28 +0000
Subject: [PATCH] Issue #3159210 by amateescu, andypost, acbramley, larowlan,
 pooja saraah, jibran, pwolanin: Support route aliasing (Symfony 5.4) and
 allow deprecating the route name

---
 core/lib/Drupal/Core/Routing/BcRoute.php      |  6 ++-
 .../lib/Drupal/Core/Routing/MatcherDumper.php | 23 +++++++++
 core/lib/Drupal/Core/Routing/RouteBuilder.php | 12 +++++
 .../lib/Drupal/Core/Routing/RouteProvider.php | 27 +++++++++-
 .../Core/Routing/RouteProviderInterface.php   | 16 +++++-
 .../Core/Routing/RouteProviderLazyBuilder.php |  7 +++
 .../src/Tests/Routing/MockRouteProvider.php   |  9 +++-
 core/modules/system/system.install            | 21 ++++++++
 .../router_test.routing.yml                   | 10 ++++
 .../src/Functional/Routing/RouterTest.php     | 49 +++++++++++++++++++
 .../Update/RouteAliasUpdateTest.php           | 37 ++++++++++++++
 .../Core/Routing/RouteProviderTest.php        | 25 ++++++++++
 .../Drupal/KernelTests/RouteProvider.php      |  7 +++
 .../Tests/Core/Routing/RoutingFixtures.php    | 30 +++++++++++-
 14 files changed, 273 insertions(+), 6 deletions(-)
 create mode 100644 core/modules/system/tests/src/Functional/Update/RouteAliasUpdateTest.php

diff --git a/core/lib/Drupal/Core/Routing/BcRoute.php b/core/lib/Drupal/Core/Routing/BcRoute.php
index e6a76587919b..d8b0d61d9529 100644
--- a/core/lib/Drupal/Core/Routing/BcRoute.php
+++ b/core/lib/Drupal/Core/Routing/BcRoute.php
@@ -14,7 +14,10 @@
  * - have an accompanying outbound route processor, that overwrites this empty
  *   route definition with the redirected route's definition.
  *
- * @see \Drupal\rest\RouteProcessor\RestResourceGetRouteProcessorBC
+ * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use route
+ * aliases instead.
+ *
+ * @see https://www.drupal.org/node/3317784
  */
 class BcRoute extends Route {
 
@@ -24,6 +27,7 @@ class BcRoute extends Route {
   public function __construct() {
     parent::__construct('');
     $this->setOption('bc_route', TRUE);
+    @trigger_error(__CLASS__ . ' is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use route aliases instead. See https://www.drupal.org/node/3317784', E_USER_DEPRECATED);
   }
 
 }
diff --git a/core/lib/Drupal/Core/Routing/MatcherDumper.php b/core/lib/Drupal/Core/Routing/MatcherDumper.php
index 78619fdd7213..4907172f7df9 100644
--- a/core/lib/Drupal/Core/Routing/MatcherDumper.php
+++ b/core/lib/Drupal/Core/Routing/MatcherDumper.php
@@ -142,6 +142,23 @@ public function dump(array $options = []): string {
         $insert->execute();
       }
 
+      // Split the aliases into chunks to avoid big INSERT queries.
+      $alias_chunks = array_chunk($this->routes->getAliases(), 50, TRUE);
+      foreach ($alias_chunks as $aliases) {
+        $insert = $this->connection->insert($this->tableName)->fields([
+          'name',
+          'route',
+          'alias',
+        ]);
+        foreach ($aliases as $name => $alias) {
+          $insert->values([
+            'name' => $name,
+            'route' => serialize($alias),
+            'alias' => $alias->getId(),
+          ]);
+        }
+        $insert->execute();
+      }
     }
     catch (\Exception $e) {
       if (isset($transaction)) {
@@ -243,9 +260,15 @@ protected function schemaDefinition() {
           'default' => 0,
           'size' => 'small',
         ],
+        'alias' => [
+          'description' => 'The alias of the route, if applicable.',
+          'type' => 'varchar_ascii',
+          'length' => 255,
+        ],
       ],
       'indexes' => [
         'pattern_outline_parts' => ['pattern_outline', 'number_parts'],
+        'alias' => ['alias'],
       ],
       'primary key' => ['name'],
     ];
diff --git a/core/lib/Drupal/Core/Routing/RouteBuilder.php b/core/lib/Drupal/Core/Routing/RouteBuilder.php
index 43dd1569b017..eb0d22b183d7 100644
--- a/core/lib/Drupal/Core/Routing/RouteBuilder.php
+++ b/core/lib/Drupal/Core/Routing/RouteBuilder.php
@@ -159,6 +159,18 @@ public function rebuild() {
         unset($routes['route_callbacks']);
       }
       foreach ($routes as $name => $route_info) {
+        if (isset($route_info['alias'])) {
+          $alias = $collection->addAlias($name, $route_info['alias']);
+          $deprecation = $route_info['deprecated'] ?? NULL;
+          if (isset($deprecation)) {
+            $alias->setDeprecated(
+              $deprecation['package'],
+              $deprecation['version'],
+              $deprecation['message'] ?? ''
+            );
+          }
+          continue;
+        }
         $route_info += [
           'defaults' => [],
           'requirements' => [],
diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php
index efcd52090b7a..db561af4d6b9 100644
--- a/core/lib/Drupal/Core/Routing/RouteProvider.php
+++ b/core/lib/Drupal/Core/Routing/RouteProvider.php
@@ -12,6 +12,7 @@
 use Drupal\Core\State\StateInterface;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Alias;
 use Symfony\Component\Routing\Exception\RouteNotFoundException;
 use Symfony\Component\Routing\RouteCollection;
 use Drupal\Core\Database\Connection;
@@ -211,7 +212,16 @@ public function getRouteByName($name) {
       throw new RouteNotFoundException(sprintf('Route "%s" does not exist.', $name));
     }
 
-    return reset($routes);
+    $result = reset($routes);
+    if ($result instanceof Alias) {
+      $alias = $result->getId();
+      if ($result->isDeprecated()) {
+        $deprecation = $result->getDeprecation($name);
+        @trigger_error($deprecation['message'], E_USER_DEPRECATED);
+      }
+      return $this->getRouteByName($alias);
+    }
+    return $result;
   }
 
   /**
@@ -405,7 +415,8 @@ protected function routeProviderRouteCompare(array $a, array $b) {
    */
   public function getAllRoutes() {
     $select = $this->connection->select($this->tableName, 'router')
-      ->fields('router', ['name', 'route']);
+      ->fields('router', ['name', 'route'])
+      ->isNull('alias');
     $routes = $select->execute()->fetchAllKeyed();
 
     $result = [];
@@ -529,4 +540,16 @@ protected function getCurrentLanguageCacheIdPart() {
     return $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_URL)->getId();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getRouteAliases(string $route_name): iterable {
+    $alias_route_names = $this->connection->select($this->tableName, 'router')
+      ->fields('router', ['name'])
+      ->condition('alias', $route_name)
+      ->execute()->fetchCol();
+
+    return $this->getRoutesByNames($alias_route_names);
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Routing/RouteProviderInterface.php b/core/lib/Drupal/Core/Routing/RouteProviderInterface.php
index b00fb773ae75..197ae5737bf4 100644
--- a/core/lib/Drupal/Core/Routing/RouteProviderInterface.php
+++ b/core/lib/Drupal/Core/Routing/RouteProviderInterface.php
@@ -69,7 +69,7 @@ public function getRouteByName($name);
    *   The list of names to retrieve, In case of null, the provider will
    *   determine what routes to return
    *
-   * @return \Symfony\Component\Routing\Route[]
+   * @return \Symfony\Component\Routing\Route|\Symfony\Component\Routing\Alias[]
    *   Iterable list with the keys being the names from the $names array
    */
   public function getRoutesByNames($names);
@@ -104,4 +104,18 @@ public function getAllRoutes();
    */
   public function reset();
 
+  /**
+   * Gets aliases for a route name.
+   *
+   * The aliases can be found using the ::getAliases() method of the returned
+   * route collection.
+   *
+   * @param string $route_name
+   *   The route name.
+   *
+   * @return iterable<\Symfony\Component\Routing\Alias>
+   *   Iterable list of aliases for the given route name.
+   */
+  public function getRouteAliases(string $route_name): iterable;
+
 }
diff --git a/core/lib/Drupal/Core/Routing/RouteProviderLazyBuilder.php b/core/lib/Drupal/Core/Routing/RouteProviderLazyBuilder.php
index 0369b2741a58..65d20600ea19 100644
--- a/core/lib/Drupal/Core/Routing/RouteProviderLazyBuilder.php
+++ b/core/lib/Drupal/Core/Routing/RouteProviderLazyBuilder.php
@@ -153,4 +153,11 @@ public function routerRebuildFinished() {
     $this->rebuilt = TRUE;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getRouteAliases(string $route_name): iterable {
+    return $this->getRouteProvider()->getRouteAliases($route_name);
+  }
+
 }
diff --git a/core/modules/system/src/Tests/Routing/MockRouteProvider.php b/core/modules/system/src/Tests/Routing/MockRouteProvider.php
index 47dc717140d9..ee629187f1f0 100644
--- a/core/modules/system/src/Tests/Routing/MockRouteProvider.php
+++ b/core/modules/system/src/Tests/Routing/MockRouteProvider.php
@@ -4,10 +4,10 @@
 
 namespace Drupal\system\Tests\Routing;
 
+use Drupal\Core\Routing\RouteProviderInterface;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\Routing\Exception\RouteNotFoundException;
 use Symfony\Component\Routing\RouteCollection;
-use Drupal\Core\Routing\RouteProviderInterface;
 
 /**
  * Easily configurable mock route provider.
@@ -93,4 +93,11 @@ public function reset() {
     $this->routes = [];
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getRouteAliases(string $route_name): iterable {
+    return new RouteCollection();
+  }
+
 }
diff --git a/core/modules/system/system.install b/core/modules/system/system.install
index 28eeff954bcb..2c85ec6bcf4e 100644
--- a/core/modules/system/system.install
+++ b/core/modules/system/system.install
@@ -1760,3 +1760,24 @@ function system_update_11102(): TranslatableMarkup|null {
   }
   return NULL;
 }
+
+/**
+ * Add the [alias] field to the {router} table.
+ */
+function system_update_11201(): void {
+  $schema = \Drupal::database()->schema();
+
+  if ($schema->tableExists('router') && !$schema->fieldExists('router', 'alias')) {
+    $spec = [
+      'fields' => [
+        'alias' => [
+          'description' => 'The alias of the route, if applicable.',
+          'type' => 'varchar_ascii',
+          'length' => 255,
+        ],
+      ],
+    ];
+    $schema->addField('router', 'alias', $spec['fields']['alias']);
+    $schema->addIndex('router', 'alias', ['alias'], $spec);
+  }
+}
diff --git a/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml b/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml
index 3267711e7be6..7e6475aba2c2 100644
--- a/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml
+++ b/core/modules/system/tests/modules/router_test_directory/router_test.routing.yml
@@ -254,3 +254,13 @@ router_test.rejects_query_strings:
     _controller: '\Drupal\router_test\TestControllers::rejectsQueryStrings'
   requirements:
     _access: 'TRUE'
+
+router_test.alias:
+  alias: 'router_test.1'
+
+router_test.deprecated:
+  alias: 'router_test.1'
+  deprecated:
+    package: 'drupal/core'
+    version: '11.2.0'
+    message: 'The "%alias_id%" route is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Use the "router_test.1" route instead.'
diff --git a/core/modules/system/tests/src/Functional/Routing/RouterTest.php b/core/modules/system/tests/src/Functional/Routing/RouterTest.php
index 46f4bc7f8618..e2446a5e858d 100644
--- a/core/modules/system/tests/src/Functional/Routing/RouterTest.php
+++ b/core/modules/system/tests/src/Functional/Routing/RouterTest.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\router_test\TestControllers;
 use Drupal\Tests\BrowserTestBase;
+use Symfony\Component\Routing\Alias;
 use Symfony\Component\Routing\Exception\RouteNotFoundException;
 use Drupal\Core\Url;
 
@@ -343,4 +344,52 @@ public function testSuccessiveSlashes(): void {
     $this->assertSession()->addressEquals($request->getUriForPath('/router_test/test1') . '?qs=test');
   }
 
+  /**
+   * Tests route aliasing.
+   */
+  public function testRouteAlias(): void {
+    $request = \Drupal::request();
+    $route_provider = \Drupal::service('router.route_provider');
+
+    // Check a simple aliased route.
+    $aliased_route_url = Url::fromRoute('router_test.alias');
+    $this->drupalGet($aliased_route_url);
+    $this->assertSession()->addressEquals($request->getUriForPath('/router_test/test1'));
+
+    $routes = $route_provider->getRoutesByNames(['router_test.alias']);
+    $aliased_route = reset($routes);
+    $this->assertTrue($aliased_route instanceof Alias);
+    $this->assertFalse($aliased_route->isDeprecated());
+
+    // Check that loading an aliased route by name returns the actual route.
+    $actual_route = $route_provider->getRouteByName('router_test.alias');
+    $this->assertFalse($actual_route instanceof Alias);
+    $this->assertEquals('/router_test/test1', $actual_route->getPath());
+  }
+
+  /**
+   * Tests route aliasing with deprecation.
+   *
+   * @group legacy
+   */
+  public function testRouteAliasWithDeprecation(): void {
+    $request = \Drupal::request();
+    $route_provider = \Drupal::service('router.route_provider');
+
+    // Check an aliased route with a deprecation message.
+    $deprecated_route_url = Url::fromRoute('router_test.deprecated');
+    $this->expectDeprecation('The "router_test.deprecated" route is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Use the "router_test.1" route instead.');
+    $this->drupalGet($deprecated_route_url);
+    $this->assertSession()->addressEquals($request->getUriForPath('/router_test/test1'));
+
+    $routes = $route_provider->getRoutesByNames(['router_test.deprecated']);
+    $deprecated_route = reset($routes);
+    $this->assertTrue($deprecated_route instanceof Alias);
+    $this->assertTrue($deprecated_route->isDeprecated());
+    $deprecation = $deprecated_route->getDeprecation('router_test.deprecated');
+    $this->assertEquals('drupal/core', $deprecation['package']);
+    $this->assertEquals('11.2.0', $deprecation['version']);
+    $this->assertEquals('The "router_test.deprecated" route is deprecated in drupal:11.2.0 and will be removed in drupal:12.0.0. Use the "router_test.1" route instead.', $deprecation['message']);
+  }
+
 }
diff --git a/core/modules/system/tests/src/Functional/Update/RouteAliasUpdateTest.php b/core/modules/system/tests/src/Functional/Update/RouteAliasUpdateTest.php
new file mode 100644
index 000000000000..1ea3ea1a5e79
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Update/RouteAliasUpdateTest.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\system\Functional\Update;
+
+use Drupal\Core\Database\Database;
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+
+/**
+ * Tests the upgrade path for the router table update.
+ *
+ * @group Update
+ */
+class RouteAliasUpdateTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles(): void {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-10.3.0.bare.standard.php.gz',
+    ];
+  }
+
+  /**
+   * Tests the upgrade path for adding aliases to the router table.
+   */
+  public function testRunUpdates(): void {
+    $connection = Database::getConnection();
+    $this->assertFalse($connection->schema()->fieldExists('router', 'alias'));
+    $this->runUpdates();
+    $this->assertTrue($connection->schema()->fieldExists('router', 'alias'));
+    $this->assertTrue($connection->schema()->indexExists('router', 'alias'));
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php
index 7727303b6324..580951930cc2 100644
--- a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php
@@ -749,6 +749,31 @@ public function testGetRoutesByPatternWithLongPatterns(): void {
     $this->assertCount(7, $candidates);
   }
 
+  /**
+   * @covers \Drupal\Core\Routing\RouteProvider::getRouteAliases
+   */
+  public function testRouteAliases(): void {
+    $connection = Database::getConnection();
+    $provider = new RouteProvider($connection, $this->state, $this->currentPath, $this->cache, $this->pathProcessor, $this->cacheTagsInvalidator, 'test_routes');
+
+    $this->fixtures->createTables($connection);
+
+    $dumper = new MatcherDumper($connection, $this->state, $this->logger, 'test_routes');
+    $dumper->addRoutes($this->fixtures->aliasedRouteCollection());
+    $dumper->dump();
+
+    $aliases = $provider->getRouteAliases('route_a');
+    $this->assertCount(2, $aliases);
+    $this->assertEquals('route_a', $aliases['route_b']->getId());
+    $this->assertEquals('route_a', $aliases['route_c']->getId());
+    $this->assertTrue($aliases['route_c']->isDeprecated());
+
+    $deprecation = $aliases['route_c']->getDeprecation('route_c');
+    $this->assertEquals('drupal/core', $deprecation['package']);
+    $this->assertEquals('11.2.0', $deprecation['version']);
+    $this->assertEquals('route_c is deprecated!', $deprecation['message']);
+  }
+
 }
 
 class TestRouteProvider extends RouteProvider {
diff --git a/core/tests/Drupal/KernelTests/RouteProvider.php b/core/tests/Drupal/KernelTests/RouteProvider.php
index 731da500f5f1..fe941842e50f 100644
--- a/core/tests/Drupal/KernelTests/RouteProvider.php
+++ b/core/tests/Drupal/KernelTests/RouteProvider.php
@@ -99,4 +99,11 @@ public function reset() {
     return $this->lazyLoadItself()->reset();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getRouteAliases(string $route_name): iterable {
+    return $this->lazyLoadItself()->getRouteAliases($route_name);
+  }
+
 }
diff --git a/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php b/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php
index d79506f7bad6..990a0addd0ac 100644
--- a/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php
+++ b/core/tests/Drupal/Tests/Core/Routing/RoutingFixtures.php
@@ -233,6 +233,27 @@ public function contentRouteCollection() {
     return $collection;
   }
 
+  /**
+   * Returns a set of routes and aliases for testing.
+   */
+  public function aliasedRouteCollection(): RouteCollection {
+    $collection = new RouteCollection();
+
+    $route = new Route('path/one');
+    $collection->add('route_a', $route);
+
+    $collection->addAlias('route_b', 'route_a');
+
+    $collection->addAlias('route_c', 'route_a')
+      ->setDeprecated(
+        'drupal/core',
+        '11.2.0',
+        '%alias_id% is deprecated!',
+      );
+
+    return $collection;
+  }
+
   /**
    * Returns the table definition for the routing fixtures.
    *
@@ -299,13 +320,20 @@ public function routingTableDefinition() {
         ],
         'route' => [
           'description' => 'A serialized Route object',
-          'type' => 'text',
+          'type' => 'blob',
+          'size' => 'big',
+        ],
+        'alias' => [
+          'description' => 'The alias of the route, if applicable.',
+          'type' => 'varchar_ascii',
+          'length' => 255,
         ],
       ],
       'indexes' => [
         'fit' => ['fit'],
         'pattern_outline' => ['pattern_outline'],
         'provider' => ['provider'],
+        'alias' => ['alias'],
       ],
       'primary key' => ['name'],
     ];
-- 
GitLab