Commit e0c5f41f authored by webchick's avatar webchick

Issue #3007669 by amateescu, Berdir, catch: Add publishing status to path aliases

parent 791a9885
......@@ -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();
......
......@@ -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;
}
......
......@@ -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.
......
......@@ -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;
......
<?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);
}
......@@ -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();
}
/**
......
......@@ -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,
],
......
......@@ -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());
}
}
}
......
......@@ -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);
......
......@@ -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);
}
......
<?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;
}
}
......@@ -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']);
}
}
/**
......
<?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;
}
}
......@@ -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();
}
/**
......
......@@ -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'));
}
}
<?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