diff --git a/og.services.yml b/og.services.yml index 1add5d435..17787794c 100644 --- a/og.services.yml +++ b/og.services.yml @@ -16,7 +16,7 @@ services: - { name: 'cache.context'} cache_context.og_role: class: 'Drupal\og\Cache\Context\OgRoleCacheContext' - arguments: ['@current_user', '@og.membership_manager', '@private_key'] + arguments: ['@current_user', '@entity_type.manager', '@og.membership_manager', '@database', '@private_key'] tags: - { name: 'cache.context'} og.access: diff --git a/src/Cache/Context/OgRoleCacheContext.php b/src/Cache/Context/OgRoleCacheContext.php index 8647ea156..9a1372f14 100644 --- a/src/Cache/Context/OgRoleCacheContext.php +++ b/src/Cache/Context/OgRoleCacheContext.php @@ -1,14 +1,21 @@ membershipManager = $membership_manager; - $this->privateKey = $private_key; + $this->entityTypeManager = $entityTypeManager; + $this->membershipManager = $membershipManager; + $this->database = $database; + $this->privateKey = $privateKey; } /** @@ -85,19 +112,11 @@ public function getContext() { // Due to cacheability metadata bubbling this can be called often. Only // compute the hash once. if (empty($this->hashes[$this->user->id()])) { - $memberships = []; - foreach ($this->membershipManager->getMemberships($this->user->id()) as $membership) { - // Derive the role names from the role IDs. This is faster than loading - // the OgRole object from the membership. - $role_names = array_map(function (string $role_id) use ($membership): string { - $pattern = preg_quote("{$membership->getGroupEntityType()}-{$membership->getGroupBundle()}-"); - preg_match("/$pattern(.+)/", $role_id, $matches); - return $matches[1]; - }, $membership->getRolesIds()); - if ($role_names) { - $memberships[$membership->getGroupEntityType()][$membership->getGroupId()] = $role_names; - } - } + // If the memberships are stored in a SQL database, use a fast SELECT + // query to retrieve the membership data. If not, fall back to loading + // the full membership entities. + $storage = $this->entityTypeManager->getStorage('og_membership'); + $memberships = $storage instanceof SqlContentEntityStorage ? $this->getMembershipsFromDatabase() : $this->getMembershipsFromEntities(); // Sort the memberships, so that the same key can be generated, even if // the memberships were defined in a different order. @@ -136,4 +155,83 @@ protected function hash($identifier) { return hash('sha256', $this->privateKey->get() . Settings::getHashSalt() . $identifier); } + /** + * Returns membership information by performing a database query. + * + * This method retrieves the membership data by doing a direct SELECT query on + * the membership database. This is very fast but can only be done on SQL + * databases since the query requires a JOIN between two tables. + * + * @return array[][] + * An array containing membership information for the current user. The data + * is in the format [$entity_type_id][$entity_id][$role_name]. + */ + protected function getMembershipsFromDatabase(): array { + $storage = $this->entityTypeManager->getStorage('og_membership'); + if (!$storage instanceof SqlContentEntityStorage) { + throw new \LogicException('Can only retrieve memberships directly from SQL databases.'); + } + + /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */ + $table_mapping = $storage->getTableMapping(); + $base_table = $table_mapping->getBaseTable(); + $role_table = $table_mapping->getFieldTableName('roles'); + $query = $this->database->select($base_table, 'm'); + $query->leftJoin($role_table, 'r', 'm.id = r.entity_id'); + $query->fields('m', ['entity_type', 'entity_bundle', 'entity_id']); + $query->fields('r', ['roles_target_id']); + $query->condition('m.uid', $this->user->id()); + $query->condition('m.state', OgMembershipInterface::STATE_ACTIVE); + + $memberships = []; + foreach ($query->execute() as $row) { + $entity_type_id = $row->entity_type; + $entity_bundle_id = $row->entity_bundle; + $entity_id = $row->entity_id; + $role_name = $row->roles_target_id; + + // If the role name is empty this is a regular authenticated user. If it + // is set we can derive the role name from the role ID. + if (empty($role_name)) { + $role_name = OgRoleInterface::AUTHENTICATED; + } + else { + $pattern = preg_quote("$entity_type_id-$entity_bundle_id-"); + preg_match("/$pattern(.+)/", $row->roles_target_id, $matches); + $role_name = $matches[1]; + } + + $memberships[$entity_type_id][$entity_id][] = $role_name; + } + + return $memberships; + } + + /** + * Returns membership information by iterating over membership entities. + * + * This method uses pure Entity API methods to retrieve the data. This is slow + * but also works with NoSQL databases. + * + * @return array[][] + * An array containing membership information for the current user. The data + * is in the format [$entity_type_id][$entity_id][$role_name]. + */ + protected function getMembershipsFromEntities(): array { + $memberships = []; + foreach ($this->membershipManager->getMemberships($this->user->id()) as $membership) { + // Derive the role names from the role IDs. This is faster than loading + // the OgRole object from the membership. + $role_names = array_map(function (string $role_id) use ($membership): string { + $pattern = preg_quote("{$membership->getGroupEntityType()}-{$membership->getGroupBundle()}-"); + preg_match("/$pattern(.+)/", $role_id, $matches); + return $matches[1]; + }, $membership->getRolesIds()); + if ($role_names) { + $memberships[$membership->getGroupEntityType()][$membership->getGroupId()] = $role_names; + } + } + return $memberships; + } + } diff --git a/tests/src/Kernel/Cache/Context/OgRoleCacheContextTest.php b/tests/src/Kernel/Cache/Context/OgRoleCacheContextTest.php new file mode 100644 index 000000000..7b4e26f5e --- /dev/null +++ b/tests/src/Kernel/Cache/Context/OgRoleCacheContextTest.php @@ -0,0 +1,248 @@ +installConfig(['og']); + $this->installEntitySchema('entity_test'); + $this->installEntitySchema('node'); + $this->installEntitySchema('og_membership'); + $this->installEntitySchema('user'); + $this->installSchema('system', ['sequences']); + + $this->database = $this->container->get('database'); + $this->entityTypeManager = $this->container->get('entity_type.manager'); + $this->groupTypeManager = $this->container->get('og.group_type_manager'); + $this->membershipManager = $this->container->get('og.membership_manager'); + $this->privateKey = $this->container->get('private_key'); + } + + /** + * Tests generating of a cache context key for a user with no memberships. + * + * This is a common case, e.g. for anonymous users. + * + * @covers ::getContext + */ + public function testNoMemberships(): void { + $user = User::getAnonymousUser(); + + // The result should be the predefined 'NO_CONTEXT' value. + $result = $this->getContextResult($user); + $this->assertEquals(OgRoleCacheContext::NO_CONTEXT, $result); + } + + /** + * Tests that the correct cache context key is returned for group members. + * + * Different users might have the identical roles across a number of different + * groups. Verify that a unique hash is returned for each combination of + * roles. + * + * This tests the main implementation for SQL databases. The fallback + * implementation for NoSQL databases is tested in a unit test. + * + * @param array $group_memberships + * An array that defines the roles test users have in test groups. See the + * data provider for a description of the format of the array. + * @param array $expected_identical_role_groups + * An array containing arrays of user IDs that are expected to have + * identical cache context keys, since they have identical memberships in + * the defined test groups. + * + * @see \Drupal\Tests\og\Unit\Cache\Context\OgRoleCacheContextTest::testMembershipsNoSql() + * + * @covers ::getContext + * @dataProvider membershipsProvider + */ + public function testMemberships(array $group_memberships, array $expected_identical_role_groups): void { + // Create a node group type. + NodeType::create([ + 'name' => $this->randomString(), + 'type' => 'group', + ])->save(); + $this->groupTypeManager->addGroup('node', 'group'); + + // The Entity Test entity doesn't have 'real' bundles, so we don't need to + // create one, we can just add the group to the fake bundle. + $this->groupTypeManager->addGroup('entity_test', 'group'); + + // Create the 'moderator' role for both group types. This is used in the + // test as a custom role in addition to the default roles 'member', + // 'administrator', etc. + foreach (['entity_test', 'node'] as $entity_type_id) { + /** @var \Drupal\og\OgRoleInterface $role */ + $role = OgRole::create(); + $role + ->setGroupType($entity_type_id) + ->setGroupBundle('group') + ->setName('moderator') + ->save(); + } + + // Create the users and memberships as required by the test. + $users = []; + $groups = []; + + foreach ($group_memberships as $user_id => $group_entity_type_ids) { + $users[$user_id] = $this->createUser(); + foreach ($group_entity_type_ids as $group_entity_type_id => $group_ids) { + foreach ($group_ids as $group_id => $roles) { + // Create the group. + if (empty($groups[$group_entity_type_id][$group_id])) { + $groups[$group_entity_type_id][$group_id] = $this->createGroup($group_entity_type_id); + } + $membership = OgMembership::create() + ->setOwner($users[$user_id]) + ->setGroup($groups[$group_entity_type_id][$group_id]); + foreach ($roles as $role_name) { + $membership->addRole(OgRole::getRole($group_entity_type_id, 'group', $role_name)); + } + $membership->save(); + } + } + } + + // Calculate the cache context keys for every user. + $cache_context_ids = []; + foreach ($users as $user_id => $user) { + $cache_context_ids[$user_id] = $this->getContextResult($user); + } + + // Loop over the expected results and check that all users that have + // identical roles have the same cache context key. + foreach ($expected_identical_role_groups as $expected_identical_role_group) { + // Check that the cache context keys for all users in the group are + // identical. + $cache_context_ids_subset = array_intersect_key($cache_context_ids, array_flip($expected_identical_role_group)); + $this->assertTrue(count(array_unique($cache_context_ids_subset)) === 1); + + // Also check that the cache context keys for the other users are + // different than the ones from our test group. + $cache_context_id_from_test_group = reset($cache_context_ids_subset); + $cache_context_ids_from_other_users = array_diff_key($cache_context_ids, array_flip($expected_identical_role_group)); + $this->assertFalse(in_array($cache_context_id_from_test_group, $cache_context_ids_from_other_users)); + } + } + + /** + * Returns the instantiated cache context service which is being tested. + * + * @return \Drupal\Core\Cache\Context\CacheContextInterface + * The instantiated cache context service. + */ + protected function getCacheContext(AccountInterface $user = NULL): CacheContextInterface { + return new OgRoleCacheContext($user, $this->entityTypeManager, $this->membershipManager, $this->database, $this->privateKey); + } + + /** + * Return a group entity with the given entity type. + * + * @param string $entity_type_id + * The entity type of the entity to create. Can be 'entity_test' or 'node'. + * + * @return \Drupal\Core\Entity\ContentEntityInterface + * The entity. + */ + protected function createGroup(string $entity_type_id): ContentEntityInterface { + switch ($entity_type_id) { + case 'node': + $group = Node::create([ + 'title' => $this->randomString(), + 'type' => 'group', + ]); + $group->save(); + break; + + default: + $group = EntityTest::create([ + 'name' => $this->randomString(), + 'type' => 'group', + ]); + $group->save(); + } + + return $group; + } + +} diff --git a/tests/src/Traits/OgRoleCacheContextTestTrait.php b/tests/src/Traits/OgRoleCacheContextTestTrait.php new file mode 100644 index 000000000..875c054dc --- /dev/null +++ b/tests/src/Traits/OgRoleCacheContextTestTrait.php @@ -0,0 +1,179 @@ +getCacheContext($user)->getContext(); + } + + /** + * Data provider for testMemberships(). + * + * Format of the user list: + * + * @code + * $user_id => [ + * $group_entity_type_id => [ + * $group_id => [ + * $role_name, + * ], + * ], + * ], + * @endcode + * + * @return array + * An array of test data, each array consisting of two arrays. The first + * array defines a list of users, the groups of which they are a member, and + * the roles the users have in the groups. It is in the format described + * above. + * The second array contains arrays of user IDs that are expected to have + * identical cache context keys, since they have identical memberships in + * the defined test groups. + * + * @see ::testMemberships() + */ + public function membershipsProvider(): array { + return [ + [ + // Set up a number of users with different roles within different + // groups. + [ + // An anonymous user which is not a member of any groups. + 0 => [], + // A user which is a normal member of three groups, one group of type + // node, and two groups of type entity_test. + 1 => [ + 'node' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + ], + 'entity_test' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + 2 => [OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which is a member of one single group. + 2 => ['entity_test' => [2 => [OgRoleInterface::AUTHENTICATED]]], + // A user which is an administrator in one group and a regular member + // in another. Note that an administrator is also a normal member, so + // the user will have two roles. + 3 => [ + 'node' => [ + 1 => [ + OgRoleInterface::AUTHENTICATED, + OgRoleInterface::ADMINISTRATOR, + ], + 2 => [OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has a custom role 'moderator' in three different + // groups. + 4 => [ + 'node' => [ + 1 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + 'entity_test' => [ + 1 => ['moderator', OgRoleInterface::AUTHENTICATED], + 2 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has the same memberships as user 1, and one additional + // membership. + 5 => [ + 'node' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + 2 => [OgRoleInterface::AUTHENTICATED], + ], + 'entity_test' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + 2 => [OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has the same memberships as user 1, but defined in a + // different order. + 6 => [ + 'entity_test' => [ + 2 => [OgRoleInterface::AUTHENTICATED], + 1 => [OgRoleInterface::AUTHENTICATED], + ], + 'node' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has the same memberships as user 3. + 7 => [ + 'node' => [ + 1 => [ + OgRoleInterface::AUTHENTICATED, + OgRoleInterface::ADMINISTRATOR, + ], + 2 => [OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has the same memberships as user 4, with the + // memberships declared in a different order. + 8 => [ + 'node' => [ + 1 => [OgRoleInterface::AUTHENTICATED, 'moderator'], + ], + 'entity_test' => [ + 1 => [OgRoleInterface::AUTHENTICATED, 'moderator'], + 2 => [OgRoleInterface::AUTHENTICATED, 'moderator'], + ], + ], + // A user which has the same memberships as user 4, with the + // memberships declared in the same order. + 9 => [ + 'node' => [ + 1 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + 'entity_test' => [ + 1 => ['moderator', OgRoleInterface::AUTHENTICATED], + 2 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + ], + // A user which has the same memberships as user 4, but with one + // role missing. + 10 => [ + 'node' => [ + 1 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + 'entity_test' => [ + 1 => [OgRoleInterface::AUTHENTICATED], + 2 => ['moderator', OgRoleInterface::AUTHENTICATED], + ], + ], + ], + // Define the users which have identical memberships and should have an + // identical hash in their cache context key. + [ + [0], + [1, 6], + [2], + [3, 7], + [4, 8, 9], + [5], + [10], + ], + ], + ]; + } + +} diff --git a/tests/src/Unit/Cache/Context/OgRoleCacheContextTest.php b/tests/src/Unit/Cache/Context/OgRoleCacheContextTest.php index 5fcfd1d4b..73615ded2 100644 --- a/tests/src/Unit/Cache/Context/OgRoleCacheContextTest.php +++ b/tests/src/Unit/Cache/Context/OgRoleCacheContextTest.php @@ -1,14 +1,19 @@ entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); $this->membershipManager = $this->prophesize(MembershipManagerInterface::class); + $this->database = $this->prophesize(Connection::class); $this->privateKey = $this->prophesize(PrivateKey::class); } @@ -49,7 +72,7 @@ public function setUp() { * * @covers ::getContext */ - public function testNoMemberships() { + public function testNoMemberships(): void { // No memberships (an empty array) will be returned by the membership // manager. /** @var \Drupal\Core\Session\AccountInterface|\Prophecy\Prophecy\ObjectProphecy $user */ @@ -71,7 +94,7 @@ public function testNoMemberships() { * * @covers ::getContext */ - public function testMembershipsWithOrphanedRole() { + public function testMembershipsWithOrphanedRole(): void { // Mock the membership with the orphaned role. It will return a group and // group entity type, but no roles. /** @var \Drupal\og\OgMembershipInterface|\Prophecy\Prophecy\ObjectProphecy $membership */ @@ -96,6 +119,9 @@ public function testMembershipsWithOrphanedRole() { * groups. Verify that a unique hash is returned for each combination of * roles. * + * This tests the fallback implementation for NoSQL databases. The main + * implementation is tested in a kernel test. + * * @param array $group_memberships * An array that defines the roles test users have in test groups. See the * data provider for a description of the format of the array. @@ -104,10 +130,12 @@ public function testMembershipsWithOrphanedRole() { * identical cache context keys, since they have identical memberships in * the defined test groups. * + * @see \Drupal\Tests\og\Kernel\Cache\Context\OgRoleCacheContextTest::testMemberships() + * * @covers ::getContext * @dataProvider membershipsProvider */ - public function testMemberships(array $group_memberships, array $expected_identical_role_groups) { + public function testMembershipsNoSql(array $group_memberships, array $expected_identical_role_groups): void { // 'Mock' the unmockable singleton that holds the Drupal settings array by // instantiating it and populating it with a random salt. new Settings(['hash_salt' => $this->randomMachineName()]); @@ -180,167 +208,8 @@ public function testMemberships(array $group_memberships, array $expected_identi /** * {@inheritdoc} */ - protected function getContextResult(AccountInterface $user = NULL) { - return $this->getCacheContext($user)->getContext(); - } - - /** - * {@inheritdoc} - */ - protected function getCacheContext(AccountInterface $user = NULL) { - return new OgRoleCacheContext($user, $this->membershipManager->reveal(), $this->privateKey->reveal()); - } - - /** - * Data provider for testMemberships(). - * - * Format of the user list: - * - * @code - * $user_id => [ - * $group_entity_type_id => [ - * $group_id => [ - * $role_name, - * ], - * ], - * ], - * @endcode - * - * @return array - * An array of test data, each array consisting of two arrays. The first - * array defines a list of users, the groups of which they are a member, and - * the roles the users have in the groups. It is in the format described - * above. - * The second array contains arrays of user IDs that are expected to have - * identical cache context keys, since they have identical memberships in - * the defined test groups. - * - * @see ::testMemberships() - */ - public function membershipsProvider() { - return [ - [ - // Set up a number of users with different roles within different - // groups. - [ - // An anonymous user which is not a member of any groups. - 0 => [], - // A user which is a normal member of three groups, one group of type - // node, and two groups of type entity_test. - 1 => [ - 'node' => [ - 1 => [OgRoleInterface::AUTHENTICATED], - ], - 'entity_test' => [ - 1 => [OgRoleInterface::AUTHENTICATED], - 2 => [OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which is a member of one single group. - 2 => ['entity_test' => [2 => [OgRoleInterface::AUTHENTICATED]]], - // A user which is an administrator in one group and a regular member - // in another. Note that an administrator is also a normal member, so - // the user will have two roles. - 3 => [ - 'node' => [ - 1 => [ - OgRoleInterface::AUTHENTICATED, - OgRoleInterface::ADMINISTRATOR, - ], - 2 => [OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has a custom role 'moderator' in three different - // groups. - 4 => [ - 'node' => [ - 1 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - 'entity_test' => [ - 1 => ['moderator', OgRoleInterface::AUTHENTICATED], - 2 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has the same memberships as user 1, and one additional - // membership. - 5 => [ - 'node' => [ - 1 => [OgRoleInterface::AUTHENTICATED], - 2 => [OgRoleInterface::AUTHENTICATED], - ], - 'entity_test' => [ - 1 => [OgRoleInterface::AUTHENTICATED], - 2 => [OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has the same memberships as user 1, but defined in a - // different order. - 6 => [ - 'entity_test' => [ - 2 => [OgRoleInterface::AUTHENTICATED], - 1 => [OgRoleInterface::AUTHENTICATED], - ], - 'node' => [ - 1 => [OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has the same memberships as user 3. - 7 => [ - 'node' => [ - 1 => [ - OgRoleInterface::AUTHENTICATED, - OgRoleInterface::ADMINISTRATOR, - ], - 2 => [OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has the same memberships as user 4, with the - // memberships declared in a different order. - 8 => [ - 'node' => [ - 1 => [OgRoleInterface::AUTHENTICATED, 'moderator'], - ], - 'entity_test' => [ - 1 => [OgRoleInterface::AUTHENTICATED, 'moderator'], - 2 => [OgRoleInterface::AUTHENTICATED, 'moderator'], - ], - ], - // A user which has the same memberships as user 4, with the - // memberships declared in the same order. - 9 => [ - 'node' => [ - 1 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - 'entity_test' => [ - 1 => ['moderator', OgRoleInterface::AUTHENTICATED], - 2 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - ], - // A user which has the same memberships as user 4, but with one - // role missing. - 10 => [ - 'node' => [ - 1 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - 'entity_test' => [ - 1 => ['moderator'], - 2 => ['moderator', OgRoleInterface::AUTHENTICATED], - ], - ], - ], - // Define the users which have identical memberships and should have an - // identical hash in their cache context key. - [ - [0], - [1, 6], - [2], - [3, 7], - [4, 8, 9], - [5], - [10], - ], - ], - ]; + protected function getCacheContext(AccountInterface $user = NULL): CacheContextInterface { + return new OgRoleCacheContext($user, $this->entityTypeManager->reveal(), $this->membershipManager->reveal(), $this->database->reveal(), $this->privateKey->reveal()); } }