diff --git a/core/lib/Drupal/Core/Path/AliasStorage.php b/core/lib/Drupal/Core/Path/AliasStorage.php index 7de344a781e0856b491a6908a423bd2855c0e7d0..5781359732c783630c6b4cb810c39e7823ff7d96 100644 --- a/core/lib/Drupal/Core/Path/AliasStorage.php +++ b/core/lib/Drupal/Core/Path/AliasStorage.php @@ -171,17 +171,30 @@ public function delete($conditions) { $storage->delete($storage->loadMultiple($result)); } + /** + * Returns a SELECT query for the path_alias base table. + * + * @return \Drupal\Core\Database\Query\SelectInterface + * A Select query object. + */ + protected function getBaseQuery() { + $query = $this->connection->select(static::TABLE, 'base_table'); + $query->condition('base_table.status', 1); + + return $query; + } + /** * {@inheritdoc} */ public function preloadPathAlias($preloaded, $langcode) { - $select = $this->connection->select(static::TABLE) - ->fields(static::TABLE, ['path', 'alias']); + $select = $this->getBaseQuery() + ->fields('base_table', ['path', 'alias']); if (!empty($preloaded)) { $conditions = new Condition('OR'); foreach ($preloaded as $preloaded_item) { - $conditions->condition('path', $this->connection->escapeLike($preloaded_item), 'LIKE'); + $conditions->condition('base_table.path', $this->connection->escapeLike($preloaded_item), 'LIKE'); } $select->condition($conditions); } @@ -191,7 +204,7 @@ public function preloadPathAlias($preloaded, $langcode) { // We order by ID ASC so that fetchAllKeyed() returns the most recently // created alias for each source. Subsequent queries using fetchField() must // use ID DESC to have the same effect. - $select->orderBy('id', 'ASC'); + $select->orderBy('base_table.id', 'ASC'); return $select->execute()->fetchAllKeyed(); } @@ -201,13 +214,13 @@ public function preloadPathAlias($preloaded, $langcode) { */ public function lookupPathAlias($path, $langcode) { // See the queries above. Use LIKE for case-insensitive matching. - $select = $this->connection->select(static::TABLE) - ->fields(static::TABLE, ['alias']) - ->condition('path', $this->connection->escapeLike($path), 'LIKE'); + $select = $this->getBaseQuery() + ->fields('base_table', ['alias']) + ->condition('base_table.path', $this->connection->escapeLike($path), 'LIKE'); $this->addLanguageFallback($select, $langcode); - $select->orderBy('id', 'DESC'); + $select->orderBy('base_table.id', 'DESC'); return $select->execute()->fetchField(); } @@ -217,13 +230,13 @@ public function lookupPathAlias($path, $langcode) { */ public function lookupPathSource($alias, $langcode) { // See the queries above. Use LIKE for case-insensitive matching. - $select = $this->connection->select(static::TABLE) - ->fields(static::TABLE, ['path']) - ->condition('alias', $this->connection->escapeLike($alias), 'LIKE'); + $select = $this->getBaseQuery() + ->fields('base_table', ['path']) + ->condition('base_table.alias', $this->connection->escapeLike($alias), 'LIKE'); $this->addLanguageFallback($select, $langcode); - $select->orderBy('id', 'DESC'); + $select->orderBy('base_table.id', 'DESC'); return $select->execute()->fetchField(); } @@ -246,12 +259,12 @@ protected function addLanguageFallback(SelectInterface $query, $langcode) { array_pop($langcode_list); } elseif ($langcode > LanguageInterface::LANGCODE_NOT_SPECIFIED) { - $query->orderBy('langcode', 'DESC'); + $query->orderBy('base_table.langcode', 'DESC'); } else { - $query->orderBy('langcode', 'ASC'); + $query->orderBy('base_table.langcode', 'ASC'); } - $query->condition('langcode', $langcode_list, 'IN'); + $query->condition('base_table.langcode', $langcode_list, 'IN'); } /** @@ -304,11 +317,11 @@ public function getAliasesForAdminListing($header, $keys = NULL) { * {@inheritdoc} */ public function pathHasMatchingAlias($initial_substring) { - $query = $this->connection->select(static::TABLE); + $query = $this->getBaseQuery(); $query->addExpression(1); return (bool) $query - ->condition('path', $this->connection->escapeLike($initial_substring) . '%', 'LIKE') + ->condition('base_table.path', $this->connection->escapeLike($initial_substring) . '%', 'LIKE') ->range(0, 1) ->execute() ->fetchField(); diff --git a/core/lib/Drupal/Core/Path/Entity/PathAlias.php b/core/lib/Drupal/Core/Path/Entity/PathAlias.php index ce96d61a0a9b71866bb833fd92cb7b753202acd1..89dd0510526271eb47fa9f956728881b54e68e26 100644 --- a/core/lib/Drupal/Core/Path/Entity/PathAlias.php +++ b/core/lib/Drupal/Core/Path/Entity/PathAlias.php @@ -3,6 +3,7 @@ namespace Drupal\Core\Path\Entity; use Drupal\Core\Entity\ContentEntityBase; +use Drupal\Core\Entity\EntityPublishedTrait; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; @@ -34,6 +35,7 @@ * "revision" = "revision_id", * "langcode" = "langcode", * "uuid" = "uuid", + * "published" = "status", * }, * admin_permission = "administer url aliases", * list_cache_tags = { "route_match" }, @@ -41,6 +43,8 @@ */ class PathAlias extends ContentEntityBase implements PathAliasInterface { + use EntityPublishedTrait; + /** * {@inheritdoc} */ @@ -61,6 +65,10 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['langcode']->setDefaultValue(LanguageInterface::LANGCODE_NOT_SPECIFIED); + // Add the published field. + $fields += static::publishedBaseFieldDefinitions($entity_type); + $fields['status']->setTranslatable(FALSE); + return $fields; } diff --git a/core/lib/Drupal/Core/Path/PathAliasInterface.php b/core/lib/Drupal/Core/Path/PathAliasInterface.php index 73d2b4569f69eb6528e73762e3cdedac64d0af13..b6f5c3b0f7768cdbc3fba615f6b04001cf61056f 100644 --- a/core/lib/Drupal/Core/Path/PathAliasInterface.php +++ b/core/lib/Drupal/Core/Path/PathAliasInterface.php @@ -3,11 +3,12 @@ namespace Drupal\Core\Path; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityPublishedInterface; /** * Provides an interface defining a path_alias entity. */ -interface PathAliasInterface extends ContentEntityInterface { +interface PathAliasInterface extends ContentEntityInterface, EntityPublishedInterface { /** * Gets the source path of the alias. diff --git a/core/lib/Drupal/Core/Path/PathAliasStorageSchema.php b/core/lib/Drupal/Core/Path/PathAliasStorageSchema.php index 2d23c85e4fda518565d5a016e29ed9fc641613c5..c79e2c77f0f196a37bb207eff3bc4b0824d51825 100644 --- a/core/lib/Drupal/Core/Path/PathAliasStorageSchema.php +++ b/core/lib/Drupal/Core/Path/PathAliasStorageSchema.php @@ -17,8 +17,8 @@ protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $res $schema = parent::getEntitySchema($entity_type, $reset); $schema[$this->storage->getBaseTable()]['indexes'] += [ - 'path_alias__alias_langcode_id' => ['alias', 'langcode', 'id'], - 'path_alias__path_langcode_id' => ['path', 'langcode', 'id'], + 'path_alias__alias_langcode_id_status' => ['alias', 'langcode', 'id', 'status'], + 'path_alias__path_langcode_id_status' => ['path', 'langcode', 'id', 'status'], ]; return $schema; diff --git a/core/lib/Drupal/Core/Routing/CacheableRouteProviderInterface.php b/core/lib/Drupal/Core/Routing/CacheableRouteProviderInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..1a5f5b0b45e35fb96fbfab8574d457cba770ac33 --- /dev/null +++ b/core/lib/Drupal/Core/Routing/CacheableRouteProviderInterface.php @@ -0,0 +1,20 @@ +<?php + +namespace Drupal\Core\Routing; + +/** + * Extends the router provider interface to provide caching support. + */ +interface CacheableRouteProviderInterface extends RouteProviderInterface { + + /** + * Adds a cache key part to be used in the cache ID of the route collection. + * + * @param string $cache_key_provider + * The provider of the cache key part. + * @param string $cache_key_part + * A string to be used as a cache key part. + */ + public function addExtraCacheKeyPart($cache_key_provider, $cache_key_part); + +} diff --git a/core/lib/Drupal/Core/Routing/RouteProvider.php b/core/lib/Drupal/Core/Routing/RouteProvider.php index d31f293e18c411c660c4c96989e0fef7625f66c9..c6355508124e35344f710907a129c7760830cadf 100644 --- a/core/lib/Drupal/Core/Routing/RouteProvider.php +++ b/core/lib/Drupal/Core/Routing/RouteProvider.php @@ -21,7 +21,7 @@ /** * A Route Provider front-end for all Drupal-stored routes. */ -class RouteProvider implements PreloadableRouteProviderInterface, PagedRouteProviderInterface, EventSubscriberInterface { +class RouteProvider implements CacheableRouteProviderInterface, PreloadableRouteProviderInterface, PagedRouteProviderInterface, EventSubscriberInterface { /** * The database connection from which to read route information. @@ -98,6 +98,13 @@ class RouteProvider implements PreloadableRouteProviderInterface, PagedRouteProv */ const ROUTE_LOAD_CID_PREFIX = 'route_provider.route_load:'; + /** + * An array of cache key parts to be used for the route match cache. + * + * @var string[] + */ + protected $extraCacheKeyParts = []; + /** * Constructs a new PathMatcher. * @@ -442,6 +449,13 @@ public function getRoutesCount() { return $this->connection->query("SELECT COUNT(*) FROM {" . $this->connection->escapeTable($this->tableName) . "}")->fetchField(); } + /** + * {@inheritdoc} + */ + public function addExtraCacheKeyPart($cache_key_provider, $cache_key_part) { + $this->extraCacheKeyParts[$cache_key_provider] = $cache_key_part; + } + /** * Returns the cache ID for the route collection cache. * @@ -455,8 +469,17 @@ protected function getRouteCollectionCacheId(Request $request) { // Include the current language code in the cache identifier as // the language information can be elsewhere than in the path, for example // based on the domain. - $language_part = $this->getCurrentLanguageCacheIdPart(); - return 'route:' . $language_part . ':' . $request->getPathInfo() . ':' . $request->getQueryString(); + $this->addExtraCacheKeyPart('language', $this->getCurrentLanguageCacheIdPart()); + + // Sort the cache key parts by their provider in order to have predictable + // cache keys. + ksort($this->extraCacheKeyParts); + $key_parts = []; + foreach ($this->extraCacheKeyParts as $provider => $key_part) { + $key_parts[] = '[' . $provider . ']=' . $key_part; + } + + return 'route:' . implode(':', $key_parts) . ':' . $request->getPathInfo() . ':' . $request->getQueryString(); } /** diff --git a/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php b/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php index 873cb2abadde8259040212cad4ebcdab879bd9ea..745f85f840847bb17c0c02b123f81eb9648e2d4c 100644 --- a/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php +++ b/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php @@ -9,6 +9,7 @@ * JSON:API integration test for the "PathAlias" content entity type. * * @group jsonapi + * @group path */ class PathAliasTest extends ResourceTestBase { @@ -86,6 +87,7 @@ protected function getExpectedDocument() { 'alias' => '/frontpage1', 'path' => '/<front>', 'langcode' => 'en', + 'status' => TRUE, 'drupal_internal__id' => 1, 'drupal_internal__revision_id' => 1, ], diff --git a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php index 0fb913b9a3954886a1f2be526ba4892b88308567..ba57f2467c9574c6f073ed27099572e3e049fb79 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php @@ -63,28 +63,42 @@ public function preSave() { * {@inheritdoc} */ public function postSave($update) { + $path_alias_storage = \Drupal::entityTypeManager()->getStorage('path_alias'); + $entity = $this->getEntity(); + // If specified, rely on the langcode property for the language, so that the // existing language of an alias can be kept. That could for example be // unspecified even if the field/entity has a specific langcode. $alias_langcode = ($this->langcode && $this->pid) ? $this->langcode : $this->getLangcode(); - if (!$update) { - if ($this->alias) { - $entity = $this->getEntity(); - if ($path = \Drupal::service('path.alias_storage')->save('/' . $entity->toUrl()->getInternalPath(), $this->alias, $alias_langcode)) { - $this->pid = $path['pid']; + // If we have an alias, we need to create or update a path alias entity. + if ($this->alias) { + if (!$update || !$this->pid) { + $path_alias = $path_alias_storage->create([ + 'path' => '/' . $entity->toUrl()->getInternalPath(), + 'alias' => $this->alias, + 'langcode' => $alias_langcode, + ]); + $path_alias->save(); + $this->pid = $path_alias->id(); + } + elseif ($this->pid) { + $path_alias = $path_alias_storage->load($this->pid); + + if ($this->alias != $path_alias->getAlias()) { + $path_alias->setAlias($this->alias); + $path_alias->save(); } } } - else { - // Delete old alias if user erased it. - if ($this->pid && !$this->alias) { - \Drupal::service('path.alias_storage')->delete(['pid' => $this->pid]); + elseif ($this->pid && !$this->alias) { + // Otherwise, delete the old alias if the user erased it. + $path_alias = $path_alias_storage->load($this->pid); + if ($entity->isDefaultRevision()) { + $path_alias_storage->delete([$path_alias]); } - // Only save a non-empty alias. - elseif ($this->alias) { - $entity = $this->getEntity(); - \Drupal::service('path.alias_storage')->save('/' . $entity->toUrl()->getInternalPath(), $this->alias, $alias_langcode, $this->pid); + else { + $path_alias_storage->deleteRevision($path_alias->getRevisionID()); } } } diff --git a/core/modules/system/system.install b/core/modules/system/system.install index cafd176ffa7acb8db5293c6d8b3f137b81325953..c7d4aa4bfc6b7a45b8c1beafd861ef6cf1d72dcf 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -2395,6 +2395,7 @@ function system_update_8803() { 'revision' => 'revision_id', 'langcode' => 'langcode', 'uuid' => 'uuid', + 'published' => 'status', ], ]); @@ -2423,6 +2424,11 @@ function system_update_8803() { ->setInternal(TRUE) ->setRevisionable(TRUE); + $field_storage_definitions['status'] = BaseFieldDefinition::create('boolean') + ->setLabel(new TranslatableMarkup('Published')) + ->setRevisionable(TRUE) + ->setDefaultValue(TRUE); + $field_storage_definitions['path'] = BaseFieldDefinition::create('string') ->setLabel(new TranslatableMarkup('System path')) ->setDescription(new TranslatableMarkup('The path that this alias belongs to.')) @@ -2472,9 +2478,9 @@ function system_update_8804(&$sandbox = NULL) { $uuid = \Drupal::service('uuid'); $base_table_insert = $database->insert('path_alias'); - $base_table_insert->fields(['id', 'revision_id', 'uuid', 'path', 'alias', 'langcode']); + $base_table_insert->fields(['id', 'revision_id', 'uuid', 'path', 'alias', 'langcode', 'status']); $revision_table_insert = $database->insert('path_alias_revision'); - $revision_table_insert->fields(['id', 'revision_id', 'path', 'alias', 'langcode', 'revision_default']); + $revision_table_insert->fields(['id', 'revision_id', 'path', 'alias', 'langcode', 'status', 'revision_default']); foreach ($url_aliases as $url_alias) { $values = [ 'id' => $url_alias->pid, @@ -2483,6 +2489,7 @@ function system_update_8804(&$sandbox = NULL) { 'path' => $url_alias->source, 'alias' => $url_alias->alias, 'langcode' => $url_alias->langcode, + 'status' => 1, ]; $base_table_insert->values($values); diff --git a/core/modules/system/tests/src/Functional/Update/PathAliasToEntityUpdateTest.php b/core/modules/system/tests/src/Functional/Update/PathAliasToEntityUpdateTest.php index 0677f061f942dfd4b5060bc6ff26cdf278015ec3..e688faab80d323596552e0057fc9ad2ef1b7708f 100644 --- a/core/modules/system/tests/src/Functional/Update/PathAliasToEntityUpdateTest.php +++ b/core/modules/system/tests/src/Functional/Update/PathAliasToEntityUpdateTest.php @@ -38,6 +38,11 @@ public function testConversionToEntities() { $query->addField('url_alias', 'source', 'path'); $query->addField('url_alias', 'alias'); $query->addField('url_alias', 'langcode'); + + // Path aliases did not have a 'status' value before the conversion to + // entities, but we're adding it here to ensure that the field was installed + // and populated correctly. + $query->addExpression('1', 'status'); $original_records = $query->execute()->fetchAllAssoc('id'); // drupal-8.filled.standard.php.gz contains one URL alias and @@ -90,12 +95,12 @@ public function testConversionToEntities() { // Check that correct data was written in both the base and the revision // tables. $base_table_records = $database->select('path_alias') - ->fields('path_alias', ['id', 'path', 'alias', 'langcode']) + ->fields('path_alias', ['id', 'path', 'alias', 'langcode', 'status']) ->execute()->fetchAllAssoc('id'); $this->assertEquals($original_records, $base_table_records); $revision_table_records = $database->select('path_alias_revision') - ->fields('path_alias_revision', ['id', 'path', 'alias', 'langcode']) + ->fields('path_alias_revision', ['id', 'path', 'alias', 'langcode', 'status']) ->execute()->fetchAllAssoc('id'); $this->assertEquals($original_records, $revision_table_records); } diff --git a/core/modules/workspaces/src/AliasStorage.php b/core/modules/workspaces/src/AliasStorage.php new file mode 100644 index 0000000000000000000000000000000000000000..219eb7f4160fa4b7f7e932025fa518800caf9795 --- /dev/null +++ b/core/modules/workspaces/src/AliasStorage.php @@ -0,0 +1,59 @@ +<?php + +namespace Drupal\workspaces; + +use Drupal\Core\Database\Connection; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Path\AliasStorage as CoreAliasStorage; + +/** + * Provides workspace-specific path alias lookup queries. + */ +class AliasStorage extends CoreAliasStorage { + + /** + * The workspace manager. + * + * @var \Drupal\workspaces\WorkspaceManagerInterface + */ + protected $workspaceManager; + + /** + * AliasStorage constructor. + * + * @param \Drupal\Core\Database\Connection $connection + * A database connection for reading and writing path aliases. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager + * The workspace manager service. + */ + public function __construct(Connection $connection, ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) { + parent::__construct($connection, $module_handler, $entity_type_manager); + $this->workspaceManager = $workspace_manager; + } + + /** + * {@inheritdoc} + */ + protected function getBaseQuery() { + // Don't alter any queries if we're not in a workspace context. + if (!$this->workspaceManager->hasActiveWorkspace()) { + return parent::getBaseQuery(); + } + + $active_workspace = $this->workspaceManager->getActiveWorkspace(); + + $query = $this->connection->select('path_alias', 'base_table_2'); + $wa_join = $query->leftJoin('workspace_association', NULL, "%alias.target_entity_type_id = 'path_alias' AND %alias.target_entity_id = base_table_2.id AND %alias.workspace = :active_workspace_id", [ + ':active_workspace_id' => $active_workspace->id(), + ]); + $query->innerJoin('path_alias_revision', 'base_table', "%alias.revision_id = COALESCE($wa_join.target_entity_revision_id, base_table_2.revision_id)"); + + return $query; + } + +} diff --git a/core/modules/workspaces/src/EntityTypeInfo.php b/core/modules/workspaces/src/EntityTypeInfo.php index d8fca35733709c6779d9d54fd31e9c545826c93c..4f0ba1a7d4673c461c69dc3a9b77d3219316217c 100644 --- a/core/modules/workspaces/src/EntityTypeInfo.php +++ b/core/modules/workspaces/src/EntityTypeInfo.php @@ -107,6 +107,11 @@ public function fieldInfoAlter(&$definitions) { if (isset($definitions['entity_reference'])) { $definitions['entity_reference']['constraints']['EntityReferenceSupportedNewEntities'] = []; } + + // Allow path aliases to be changed in workspace-specific pending revisions. + if (isset($definitions['path'])) { + unset($definitions['path']['constraints']['PathAlias']); + } } /** diff --git a/core/modules/workspaces/src/EventSubscriber/WorkspaceRequestSubscriber.php b/core/modules/workspaces/src/EventSubscriber/WorkspaceRequestSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..e44d36b02ad45364afa7b7b642672b4c730107b7 --- /dev/null +++ b/core/modules/workspaces/src/EventSubscriber/WorkspaceRequestSubscriber.php @@ -0,0 +1,110 @@ +<?php + +namespace Drupal\workspaces\EventSubscriber; + +use Drupal\Core\Path\AliasManagerInterface; +use Drupal\Core\Path\CurrentPathStack; +use Drupal\Core\Routing\CacheableRouteProviderInterface; +use Drupal\Core\Routing\RouteProviderInterface; +use Drupal\workspaces\WorkspaceManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\FilterControllerEvent; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Provides a event subscriber for setting workspace-specific cache keys. + */ +class WorkspaceRequestSubscriber implements EventSubscriberInterface { + + /** + * The alias manager that caches alias lookups based on the request. + * + * @var \Drupal\Core\Path\AliasManagerInterface + */ + protected $aliasManager; + + /** + * The current path. + * + * @var \Drupal\Core\Path\CurrentPathStack + */ + protected $currentPath; + + /** + * The route provider to load routes by name. + * + * @var \Drupal\Core\Routing\RouteProviderInterface + */ + protected $routeProvider; + + /** + * The workspace manager. + * + * @var \Drupal\workspaces\WorkspaceManagerInterface + */ + protected $workspaceManager; + + /** + * Constructs a new WorkspaceRequestSubscriber instance. + * + * @param \Drupal\Core\Path\AliasManagerInterface $alias_manager + * The alias manager. + * @param \Drupal\Core\Path\CurrentPathStack $current_path + * The current path. + * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider + * The route provider. + * @param \Drupal\workspaces\WorkspaceManagerInterface $workspace_manager + * The workspace manager. + */ + public function __construct(AliasManagerInterface $alias_manager, CurrentPathStack $current_path, RouteProviderInterface $route_provider, WorkspaceManagerInterface $workspace_manager) { + $this->aliasManager = $alias_manager; + $this->currentPath = $current_path; + $this->routeProvider = $route_provider; + $this->workspaceManager = $workspace_manager; + } + + /** + * Sets the cache key on the alias manager cache decorator. + * + * KernelEvents::CONTROLLER is used in order to be executed after routing. + * + * @param \Symfony\Component\HttpKernel\Event\FilterControllerEvent $event + * The Event to process. + */ + public function onKernelController(FilterControllerEvent $event) { + // Set the cache key on the alias manager cache decorator. + if ($event->isMasterRequest() && $this->workspaceManager->hasActiveWorkspace()) { + $cache_key = $this->workspaceManager->getActiveWorkspace()->id() . ':' . rtrim($this->currentPath->getPath($event->getRequest()), '/'); + $this->aliasManager->setCacheKey($cache_key); + } + } + + /** + * Adds the active workspace as a cache key part to the route provider. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event + * An event object. + */ + public function onKernelRequest(GetResponseEvent $event) { + if ($this->workspaceManager->hasActiveWorkspace() && $this->routeProvider instanceof CacheableRouteProviderInterface) { + $this->routeProvider->addExtraCacheKeyPart('workspace', $this->workspaceManager->getActiveWorkspace()->id()); + } + } + + /** + * {@inheritDoc} + */ + public static function getSubscribedEvents() { + // Use a priority of 190 in order to run after the generic core subscriber. + // @see \Drupal\Core\EventSubscriber\PathSubscriber::getSubscribedEvents() + $events[KernelEvents::CONTROLLER][] = ['onKernelController', 190]; + + // Use a priority of 33 in order to run before Symfony's router listener. + // @see \Symfony\Component\HttpKernel\EventListener\RouterListener::getSubscribedEvents() + $events[KernelEvents::REQUEST][] = ['onKernelRequest', 33]; + + return $events; + } + +} diff --git a/core/modules/workspaces/src/WorkspaceManager.php b/core/modules/workspaces/src/WorkspaceManager.php index b53be3613025a3e12416a50da5fea8a57ea35164..499c5cd786a45c45fd4b8afa419e505255e4c407 100644 --- a/core/modules/workspaces/src/WorkspaceManager.php +++ b/core/modules/workspaces/src/WorkspaceManager.php @@ -262,6 +262,10 @@ protected function doSwitchWorkspace($workspace) { return 'entity.memory_cache:' . $entity_type_id; }, array_keys($this->getSupportedEntityTypes())); $this->entityMemoryCache->invalidateTags($cache_tags_to_invalidate); + + // Clear the static cache for path aliases. We can't inject the path alias + // manager service because it would create a circular dependency. + \Drupal::service('path.alias_manager')->cacheClear(); } /** diff --git a/core/modules/workspaces/src/WorkspacesServiceProvider.php b/core/modules/workspaces/src/WorkspacesServiceProvider.php index 685575f2eff1476e9e5d623b8cb0858f5e34f4c1..598ae742d7c3d756f28d2984abcfe0f2250e3630 100644 --- a/core/modules/workspaces/src/WorkspacesServiceProvider.php +++ b/core/modules/workspaces/src/WorkspacesServiceProvider.php @@ -4,6 +4,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Symfony\Component\DependencyInjection\Reference; /** * Defines a service provider for the Workspaces module. @@ -18,6 +19,11 @@ public function alter(ContainerBuilder $container) { $renderer_config = $container->getParameter('renderer.config'); $renderer_config['required_cache_contexts'][] = 'workspace'; $container->setParameter('renderer.config', $renderer_config); + + // Replace the class of the 'path.alias_storage' service. + $container->getDefinition('path.alias_storage') + ->setClass(AliasStorage::class) + ->addArgument(new Reference('workspaces.manager')); } } diff --git a/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php b/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..78ae786c52768b8b8b40de4b5a785d3421683ecd --- /dev/null +++ b/core/modules/workspaces/tests/src/Functional/PathWorkspacesTest.php @@ -0,0 +1,309 @@ +<?php + +namespace Drupal\Tests\workspaces\Functional; + +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\Tests\BrowserTestBase; +use Drupal\workspaces\Entity\Workspace; + +/** + * Tests path aliases with workspaces. + * + * @group path + * @group workspaces + */ +class PathWorkspacesTest extends BrowserTestBase { + + use WorkspaceTestUtilities; + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'content_translation', 'node', 'path', 'workspaces']; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + ConfigurableLanguage::createFromLangcode('ro')->save(); + $this->rebuildContainer(); + + // Create a content type. + $this->drupalCreateContentType([ + 'name' => 'article', + 'type' => 'article', + ]); + + $this->drupalLogin($this->rootUser); + + // Enable URL language detection and selection. + $edit = ['language_interface[enabled][language-url]' => 1]; + $this->drupalPostForm('admin/config/regional/language/detection', $edit, 'Save settings'); + + // Enable translation for article node. + $edit = [ + 'entity_types[node]' => 1, + 'settings[node][article][translatable]' => 1, + 'settings[node][article][fields][path]' => 1, + 'settings[node][article][fields][body]' => 1, + 'settings[node][article][settings][language][language_alterable]' => 1, + ]; + $this->drupalPostForm('admin/config/regional/content-language', $edit, 'Save configuration'); + \Drupal::entityTypeManager()->clearCachedDefinitions(); + + $this->setupWorkspaceSwitcherBlock(); + } + + /** + * Tests path aliases with workspaces. + */ + public function testPathAliases() { + // Create a published node in Live, without an alias. + $node = $this->drupalCreateNode([ + 'type' => 'article', + 'status' => TRUE, + ]); + + // Switch to Stage and create an alias for the node. + $stage = Workspace::load('stage'); + $this->switchToWorkspace($stage); + + $edit = [ + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save'); + + // Check that the node can be accessed in Stage with the given alias. + $path = $edit['path[0][alias]']; + $this->assertAccessiblePaths([$path]); + + // Check that the 'preload-paths' cache includes the active workspace ID in + // the cache key. + $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:stage:/node/1')); + $this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1')); + + // Check that the alias can not be accessed in Live. + $this->switchToLive(); + $this->assertNotAccessiblePaths([$path]); + $this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1')); + + // Publish the workspace and check that the alias can be accessed in Live. + $stage->publish(); + $this->assertAccessiblePaths([$path]); + $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:/node/1')); + } + + /** + * Tests path aliases with workspaces and user switching. + */ + public function testPathAliasesUserSwitch() { + // Create a published node in Live, without an alias. + $node = $this->drupalCreateNode([ + 'type' => 'article', + 'status' => TRUE, + ]); + + // Switch to Stage and create an alias for the node. + $stage = Workspace::load('stage'); + $this->switchToWorkspace($stage); + + $edit = [ + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, 'Save'); + + // Check that the node can be accessed in Stage with the given alias. + $path = $edit['path[0][alias]']; + $this->assertAccessiblePaths([$path]); + + // Check that the 'preload-paths' cache includes the active workspace ID in + // the cache key. + $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:stage:/node/1')); + $this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1')); + + // Check that the alias can not be accessed in Live, by logging out without + // an explicit switch. + $this->drupalLogout(); + $this->assertNotAccessiblePaths([$path]); + $this->assertFalse(\Drupal::cache('data')->get('preload-paths:/node/1')); + + // Publish the workspace and check that the alias can be accessed in Live. + $this->drupalLogin($this->rootUser); + $stage->publish(); + $this->drupalLogout(); + $this->assertAccessiblePaths([$path]); + $this->assertNotEmpty(\Drupal::cache('data')->get('preload-paths:/node/1')); + } + + /** + * Tests path aliases with workspaces for translatable nodes. + */ + public function testPathAliasesWithTranslation() { + $stage = Workspace::load('stage'); + + // Create one node with a random alias. + $default_node = $this->drupalCreateNode([ + 'type' => 'article', + 'langcode' => 'en', + 'status' => TRUE, + 'path' => '/' . $this->randomMachineName(), + ]); + + // Add published translation with another alias. + $this->drupalGet('node/' . $default_node->id()); + $this->drupalGet('node/' . $default_node->id() . '/translations'); + $this->clickLink('Add'); + $edit_translation = [ + 'body[0][value]' => $this->randomMachineName(), + 'status[value]' => TRUE, + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm(NULL, $edit_translation, 'Save (this translation)'); + // Confirm that the alias works. + $this->drupalGet('ro' . $edit_translation['path[0][alias]']); + $this->assertSession()->pageTextContains($edit_translation['body[0][value]']); + + $default_path = $default_node->path->alias; + $translation_path = 'ro' . $edit_translation['path[0][alias]']; + + $this->assertAccessiblePaths([$default_path, $translation_path]); + + $this->switchToWorkspace($stage); + + $this->assertAccessiblePaths([$default_path, $translation_path]); + + // Create a workspace-specific revision for the translation with a new path + // alias. + $edit_new_translation_draft_with_alias = [ + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('ro/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_with_alias, 'Save (this translation)'); + $stage_translation_path = 'ro' . $edit_new_translation_draft_with_alias['path[0][alias]']; + + // The new alias of the translation should be available in Stage, but not + // available in Live. + $this->assertAccessiblePaths([$default_path, $stage_translation_path]); + + // Check that the previous (Live) path alias no longer works. + $this->assertNotAccessiblePaths([$translation_path]); + + // Switch out of Stage and check that the initial path aliases still work. + $this->switchToLive(); + $this->assertAccessiblePaths([$default_path, $translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + + // Switch back to Stage. + $this->switchToWorkspace($stage); + + // Create new workspace-specific revision for translation without changing + // the path alias. + $edit_new_translation_draft = [ + 'body[0][value]' => $this->randomMachineName(), + ]; + $this->drupalPostForm('ro/node/' . $default_node->id() . '/edit', $edit_new_translation_draft, t('Save (this translation)')); + // Confirm that the new draft revision was created. + $this->assertSession()->pageTextContains($edit_new_translation_draft['body[0][value]']); + + // Switch out of Stage and check that the initial path aliases still work. + $this->switchToLive(); + $this->assertAccessiblePaths([$default_path, $translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + + // Switch back to Stage. + $this->switchToWorkspace($stage); + $this->assertAccessiblePaths([$default_path, $stage_translation_path]); + $this->assertNotAccessiblePaths([$translation_path]); + + // Create a new workspace-specific revision for translation with path alias + // from the original language's default revision. + $edit_new_translation_draft_with_defaults_alias = [ + 'path[0][alias]' => $default_node->path->alias, + ]; + $this->drupalPostForm('ro/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_with_defaults_alias, 'Save (this translation)'); + + // Switch out of Stage and check that the initial path aliases still work. + $this->switchToLive(); + $this->assertAccessiblePaths([$default_path, $translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + + // Check that only one path alias (the original one) is available in Stage. + $this->switchToWorkspace($stage); + $this->assertAccessiblePaths([$default_path]); + $this->assertNotAccessiblePaths([$translation_path, $stage_translation_path]); + + // Create new workspace-specific revision for translation with a deleted + // (empty) path alias. + $edit_new_translation_draft_empty_alias = [ + 'body[0][value]' => $this->randomMachineName(), + 'path[0][alias]' => '', + ]; + $this->drupalPostForm('ro/node/' . $default_node->id() . '/edit', $edit_new_translation_draft_empty_alias, 'Save (this translation)'); + + // Check that only one path alias (the original one) is available now. + $this->switchToLive(); + $this->assertAccessiblePaths([$default_path, $translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + + $this->switchToWorkspace($stage); + $this->assertAccessiblePaths([$default_path]); + $this->assertNotAccessiblePaths([$translation_path, $stage_translation_path]); + + // Create a new workspace-specific revision for the translation with a new + // path alias. + $edit_new_translation = [ + 'body[0][value]' => $this->randomMachineName(), + 'path[0][alias]' => '/' . $this->randomMachineName(), + ]; + $this->drupalPostForm('ro/node/' . $default_node->id() . '/edit', $edit_new_translation, 'Save (this translation)'); + + // Confirm that the new revision was created. + $this->assertSession()->pageTextContains($edit_new_translation['body[0][value]']); + $this->assertSession()->addressEquals('ro' . $edit_new_translation['path[0][alias]']); + + // Check that only the new path alias of the translation can be accessed. + $new_stage_translation_path = 'ro' . $edit_new_translation['path[0][alias]']; + $this->assertAccessiblePaths([$default_path, $new_stage_translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + + // Switch out of Stage and check that none of the workspace-specific path + // aliases can be accessed. + $this->switchToLive(); + $this->assertAccessiblePaths([$default_path, $translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path, $new_stage_translation_path]); + + // Publish Stage and check that its path alias for the translation can be + // accessed. + $stage->publish(); + $this->assertAccessiblePaths([$default_path, $new_stage_translation_path]); + $this->assertNotAccessiblePaths([$stage_translation_path]); + } + + /** + * Helper callback to verify paths are responding with status 200. + * + * @param string[] $paths + * An array of paths to check for. + */ + protected function assertAccessiblePaths(array $paths) { + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(200); + } + } + + /** + * Helper callback to verify paths are responding with status 404. + * + * @param string[] $paths + * An array of paths to check for. + */ + protected function assertNotAccessiblePaths(array $paths) { + foreach ($paths as $path) { + $this->drupalGet($path); + $this->assertSession()->statusCodeEquals(404); + } + } + +} diff --git a/core/modules/workspaces/tests/src/Unit/WorkspaceRequestSubscriberTest.php b/core/modules/workspaces/tests/src/Unit/WorkspaceRequestSubscriberTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e5f109832d0cae7bc227ff03335a0aba78474f2e --- /dev/null +++ b/core/modules/workspaces/tests/src/Unit/WorkspaceRequestSubscriberTest.php @@ -0,0 +1,82 @@ +<?php + +namespace Drupal\Tests\workspaces\Unit; + +use Drupal\Core\Path\AliasManagerInterface; +use Drupal\Core\Path\CurrentPathStack; +use Drupal\Core\Routing\CacheableRouteProviderInterface; +use Drupal\Core\Routing\RouteProviderInterface; +use Drupal\Tests\UnitTestCase; +use Drupal\workspaces\EventSubscriber\WorkspaceRequestSubscriber; +use Drupal\workspaces\WorkspaceInterface; +use Drupal\workspaces\WorkspaceManagerInterface; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +/** + * @coversDefaultClass \Drupal\workspaces\EventSubscriber\WorkspaceRequestSubscriber + * + * @group workspace + */ +class WorkspaceRequestSubscriberTest extends UnitTestCase { + + /** + * @var \Drupal\Core\Path\AliasManagerInterface + */ + protected $aliasManager; + + /** + * @var \Drupal\Core\Path\CurrentPathStack + */ + protected $currentPath; + + /** + * @var \Drupal\workspaces\WorkspaceManagerInterface + */ + protected $workspaceManager; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->aliasManager = $this->prophesize(AliasManagerInterface::class)->reveal(); + $this->currentPath = $this->prophesize(CurrentPathStack::class)->reveal(); + $this->workspaceManager = $this->prophesize(WorkspaceManagerInterface::class); + + $active_workspace = $this->prophesize(WorkspaceInterface::class); + $active_workspace->id()->willReturn('test'); + $this->workspaceManager->getActiveWorkspace()->willReturn($active_workspace->reveal()); + $this->workspaceManager->hasActiveWorkspace()->willReturn(TRUE); + } + + /** + * @covers ::onKernelRequest + */ + public function testOnKernelRequestWithCacheableRouteProvider() { + $route_provider = $this->prophesize(CacheableRouteProviderInterface::class); + $route_provider->addExtraCacheKeyPart('workspace', 'test')->shouldBeCalled(); + + // Check that WorkspaceRequestSubscriber::onKernelRequest() calls + // addExtraCacheKeyPart() on a route provider that implements + // CacheableRouteProviderInterface. + $workspace_request_subscriber = new WorkspaceRequestSubscriber($this->aliasManager, $this->currentPath, $route_provider->reveal(), $this->workspaceManager->reveal()); + $event = $this->prophesize(GetResponseEvent::class)->reveal(); + $this->assertNull($workspace_request_subscriber->onKernelRequest($event)); + } + + /** + * @covers ::onKernelRequest + */ + public function testOnKernelRequestWithoutCacheableRouteProvider() { + $route_provider = $this->prophesize(RouteProviderInterface::class); + + // Check that WorkspaceRequestSubscriber::onKernelRequest() doesn't call + // addExtraCacheKeyPart() on a route provider that does not implement + // CacheableRouteProviderInterface. + $workspace_request_subscriber = new WorkspaceRequestSubscriber($this->aliasManager, $this->currentPath, $route_provider->reveal(), $this->workspaceManager->reveal()); + $event = $this->prophesize(GetResponseEvent::class)->reveal(); + $this->assertNull($workspace_request_subscriber->onKernelRequest($event)); + } + +} diff --git a/core/modules/workspaces/workspaces.services.yml b/core/modules/workspaces/workspaces.services.yml index 45f1d50746141b382da9faf3884bdcdf826716a1..358cfc23a7c7a62bc80a0f63656fe8091e8274c4 100644 --- a/core/modules/workspaces/workspaces.services.yml +++ b/core/modules/workspaces/workspaces.services.yml @@ -35,6 +35,11 @@ services: arguments: ['@entity.definition_update_manager', '@entity.last_installed_schema.repository', '@workspaces.manager'] tags: - { name: 'event_subscriber' } + workspaces.workspace_subscriber: + class: Drupal\workspaces\EventSubscriber\WorkspaceRequestSubscriber + arguments: ['@path.alias_manager', '@path.current', '@router.route_provider', '@workspaces.manager'] + tags: + - { name: event_subscriber } cache_context.workspace: class: Drupal\workspaces\WorkspaceCacheContext diff --git a/core/tests/Drupal/FunctionalTests/Rest/PathAliasResourceTestBase.php b/core/tests/Drupal/FunctionalTests/Rest/PathAliasResourceTestBase.php index 29adab111dc162fc444017081d1eb7d5197688e3..784a0230ea93bb0424f79074cfd3b35be491ca11 100644 --- a/core/tests/Drupal/FunctionalTests/Rest/PathAliasResourceTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Rest/PathAliasResourceTestBase.php @@ -83,6 +83,11 @@ protected function getExpectedNormalizedEntity() { 'value' => '/frontpage1', ], ], + 'status' => [ + [ + 'value' => TRUE, + ], + ], 'uuid' => [ [ 'value' => $this->entity->uuid(), diff --git a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php index 171968792287b0ab38c57e410692a2f0f58e668d..f439164c4e257b91844e7cb17dd11508dd119e28 100644 --- a/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php +++ b/core/tests/Drupal/KernelTests/Core/Routing/RouteProviderTest.php @@ -546,7 +546,7 @@ public function testRouteCaching() { $request = Request::create($path, 'GET'); $provider->getRouteCollectionForRequest($request); - $cache = $this->cache->get('route:en:/path/add/one:'); + $cache = $this->cache->get('route:[language]=en:/path/add/one:'); $this->assertEqual('/path/add/one', $cache->data['path']); $this->assertEqual([], $cache->data['query']); $this->assertEqual(3, count($cache->data['routes'])); @@ -556,7 +556,7 @@ public function testRouteCaching() { $request = Request::create($path, 'GET'); $provider->getRouteCollectionForRequest($request); - $cache = $this->cache->get('route:en:/path/add/one:foo=bar'); + $cache = $this->cache->get('route:[language]=en:/path/add/one:foo=bar'); $this->assertEqual('/path/add/one', $cache->data['path']); $this->assertEqual(['foo' => 'bar'], $cache->data['query']); $this->assertEqual(3, count($cache->data['routes'])); @@ -566,7 +566,7 @@ public function testRouteCaching() { $request = Request::create($path, 'GET'); $provider->getRouteCollectionForRequest($request); - $cache = $this->cache->get('route:en:/path/1/one:'); + $cache = $this->cache->get('route:[language]=en:/path/1/one:'); $this->assertEqual('/path/1/one', $cache->data['path']); $this->assertEqual([], $cache->data['query']); $this->assertEqual(2, count($cache->data['routes'])); @@ -583,7 +583,7 @@ public function testRouteCaching() { $request = Request::create($path, 'GET'); $provider->getRouteCollectionForRequest($request); - $cache = $this->cache->get('route:en:/path/add-one:'); + $cache = $this->cache->get('route:[language]=en:/path/add-one:'); $this->assertEqual('/path/add/one', $cache->data['path']); $this->assertEqual([], $cache->data['query']); $this->assertEqual(3, count($cache->data['routes'])); @@ -598,7 +598,7 @@ public function testRouteCaching() { $request = Request::create($path, 'GET'); $provider->getRouteCollectionForRequest($request); - $cache = $this->cache->get('route:gsw-berne:/path/add-one:'); + $cache = $this->cache->get('route:[language]=gsw-berne:/path/add-one:'); $this->assertEquals('/path/add/one', $cache->data['path']); $this->assertEquals([], $cache->data['query']); $this->assertEquals(3, count($cache->data['routes']));