Skip to content

Commit 79810f6

Browse files
committed
fix: eliminate N+1 group_user queries when serializing users
The UserResource `groups` relationship getter issued a fresh `group_user` query for every serialized user, so the query count grew linearly with the number of users in a payload (e.g. one per post author on the posts endpoint). The getter now reads the (eager-)loaded `groups` relation and filters hidden groups in PHP, and the relevant endpoints eager-load `groups` so it is batched into a single query. This also shares the relation that `User::isAdmin()` reads (via the `email` field's `editCredentials` check), removing the redundant per-user load, and the email/isEmailConfirmed visibility checks now short-circuit on the cheap self comparison before invoking the policy. Fixes #4695
1 parent bfbffc6 commit 79810f6

6 files changed

Lines changed: 325 additions & 15 deletions

File tree

framework/core/src/Api/Resource/DiscussionResource.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ public function endpoints(): array
9494
'firstPost.editedUser',
9595
'firstPost.hiddenUser',
9696
'lastPost'
97-
]),
97+
])
98+
->eagerLoad(['state', 'user.groups', 'lastPostedUser.groups', 'firstPost.user.groups']),
9899
Endpoint\Index::make()
99100
->defaultInclude([
100101
'user',

framework/core/src/Api/Resource/PostResource.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,8 @@ public function endpoints(): array
119119
'editedUser',
120120
'hiddenUser',
121121
'discussion'
122-
]),
122+
])
123+
->eagerLoad(['user.groups']),
123124
Endpoint\Index::make()
124125
->extractOffset(function (Context $context, array $defaultExtracts): int {
125126
$queryParams = $context->request->getQueryParams();
@@ -150,6 +151,7 @@ public function endpoints(): array
150151
'hiddenUser',
151152
'discussion'
152153
])
154+
->eagerLoad(['user.groups'])
153155
->defaultSort('number')
154156
->paginate(static::$defaultLimit),
155157
];

framework/core/src/Api/Resource/UserResource.php

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Flarum\Api\Sort\SortColumn;
1616
use Flarum\Bus\Dispatcher;
1717
use Flarum\Foundation\ValidationException;
18+
use Flarum\Group\Group;
1819
use Flarum\Http\SlugManager;
1920
use Flarum\Locale\TranslatorInterface;
2021
use Flarum\Settings\SettingsRepositoryInterface;
@@ -111,15 +112,18 @@ public function endpoints(): array
111112

112113
return true;
113114
})
114-
->defaultInclude(['groups']),
115+
->defaultInclude(['groups'])
116+
->eagerLoad(['groups']),
115117
Endpoint\Delete::make()
116118
->authenticated()
117119
->can('delete'),
118120
Endpoint\Show::make()
119-
->defaultInclude(['groups']),
121+
->defaultInclude(['groups'])
122+
->eagerLoad(['groups']),
120123
Endpoint\Index::make()
121124
->can('searchUsers')
122125
->defaultInclude(['groups'])
126+
->eagerLoad(['groups'])
123127
->paginate(),
124128
Endpoint\Endpoint::make('avatar.upload')
125129
->route('POST', '/{id}/avatar')
@@ -174,13 +178,16 @@ public function fields(): array
174178
->email(['filter'])
175179
->unique('users', 'email', true)
176180
->visible(function (User $user, Context $context) {
177-
return $context->getActor()->can('editCredentials', $user)
178-
|| $context->getActor()->id === $user->id;
181+
// Check the cheap self comparison before the editCredentials
182+
// policy, which would otherwise read $user->groups (isAdmin) for
183+
// every serialized user.
184+
return $context->getActor()->id === $user->id
185+
|| $context->getActor()->can('editCredentials', $user);
179186
})
180187
->writable(function (User $user, Context $context) {
181188
return $context->creating()
182-
|| $context->getActor()->can('editCredentials', $user)
183-
|| $context->getActor()->id === $user->id;
189+
|| $context->getActor()->id === $user->id
190+
|| $context->getActor()->can('editCredentials', $user);
184191
})
185192
->set(function (User $user, string $value, Context $context) {
186193
if ($user->exists) {
@@ -198,8 +205,8 @@ public function fields(): array
198205
}),
199206
Schema\Boolean::make('isEmailConfirmed')
200207
->visible(function (User $user, Context $context) {
201-
return $context->getActor()->can('editCredentials', $user)
202-
|| $context->getActor()->id === $user->id;
208+
return $context->getActor()->id === $user->id
209+
|| $context->getActor()->can('editCredentials', $user);
203210
})
204211
->writable(fn (User $user, Context $context) => $context->getActor()->isAdmin())
205212
->set(function (User $user, $value, Context $context) {
@@ -311,9 +318,21 @@ public function fields(): array
311318

312319
Schema\Relationship\ToMany::make('groups')
313320
->get(function (User $user, Context $context) {
314-
return $context->getActor()->can('viewHiddenGroups')
315-
? $user->groups()->get()->all()
316-
: $user->visibleGroups()->get()->all();
321+
// Read the (eager-)loaded relation instead of issuing a fresh
322+
// query per serialized user. Hidden groups are filtered in PHP
323+
// for actors that cannot view them, so the relation can be
324+
// eager-loaded once for the whole payload (see the endpoints'
325+
// eagerLoad of `groups`). Note: isAdmin() also reads $user->groups,
326+
// so the relation must contain all groups (filtering happens here).
327+
$groups = $user->groups;
328+
329+
if (! $context->getActor()->can('viewHiddenGroups')) {
330+
// values() re-indexes after filtering so the result stays a
331+
// sequential list (JSON array) rather than a keyed object.
332+
$groups = $groups->filter(fn (Group $group) => ! $group->is_hidden)->values();
333+
}
334+
335+
return $groups->all();
317336
})
318337
->writable(fn (User $user, Context $context) => $context->updating() && $context->getActor()->can('editGroups', $user))
319338
->includable()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\api\discussions;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Discussion\Discussion;
14+
use Flarum\Group\Group;
15+
use Flarum\Post\Post;
16+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
17+
use Flarum\Testing\integration\TestCase;
18+
use Flarum\User\User;
19+
use PHPUnit\Framework\Attributes\Test;
20+
21+
/**
22+
* Regression test for part (B) of https://github.com/flarum/framework/issues/4695:
23+
* DiscussionResource::Show did not eager-load groups for the `user` and
24+
* `lastPostedUser` it serialises, so the `email` field's
25+
* `editCredentials`/`isAdmin()` check lazy-loaded each user's groups in its own
26+
* `group_user` query.
27+
*/
28+
class ShowGroupsQueryCountTest extends TestCase
29+
{
30+
use RetrievesAuthorizedUsers;
31+
32+
protected function setUp(): void
33+
{
34+
parent::setUp();
35+
36+
$this->prepareDatabase([
37+
Discussion::class => [
38+
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 20, 'last_posted_user_id' => 21, 'first_post_id' => 1, 'comment_count' => 1],
39+
],
40+
Post::class => [
41+
['id' => 1, 'number' => 1, 'discussion_id' => 1, 'created_at' => Carbon::now(), 'user_id' => 22, 'type' => 'comment', 'content' => '<t><p>first post</p></t>'],
42+
],
43+
User::class => [
44+
$this->normalUser(),
45+
['id' => 20, 'username' => 'author', 'email' => 'author@machine.local', 'is_email_confirmed' => 1, 'password' => 'foobar'],
46+
['id' => 21, 'username' => 'lastposter', 'email' => 'lastposter@machine.local', 'is_email_confirmed' => 1, 'password' => 'foobar'],
47+
['id' => 22, 'username' => 'firstposter', 'email' => 'firstposter@machine.local', 'is_email_confirmed' => 1, 'password' => 'foobar'],
48+
],
49+
Group::class => [
50+
['id' => 100, 'name_singular' => 'Visible', 'name_plural' => 'Visible', 'is_hidden' => 0],
51+
],
52+
'group_user' => [
53+
['user_id' => 20, 'group_id' => 100],
54+
['user_id' => 21, 'group_id' => 100],
55+
['user_id' => 22, 'group_id' => 100],
56+
],
57+
]);
58+
}
59+
60+
private function countGroupUserQueries(): int
61+
{
62+
$db = $this->database();
63+
$db->flushQueryLog();
64+
$db->enableQueryLog();
65+
66+
// Authenticate as the normal user (id 2): not the author, last poster or
67+
// first poster, so the email field's editCredentials/isAdmin check runs
68+
// against each of those users.
69+
$response = $this->send(
70+
$this->request('GET', '/api/discussions/1', ['authenticatedAs' => 2])
71+
);
72+
73+
$this->assertEquals(200, $response->getStatusCode(), $response->getBody()->getContents());
74+
75+
$count = 0;
76+
foreach ($db->getQueryLog() as $query) {
77+
if (stripos($query['query'], 'group_user') !== false) {
78+
$count++;
79+
}
80+
}
81+
82+
$db->disableQueryLog();
83+
84+
return $count;
85+
}
86+
87+
#[Test]
88+
public function user_groups_are_eager_loaded_on_discussion_show()
89+
{
90+
$this->app();
91+
92+
$count = $this->countGroupUserQueries();
93+
94+
// The discussion serialises three distinct users with groups (author,
95+
// last poster, first-post author) plus the actor, so each user's groups
96+
// are loaded exactly once: 3 + 1 = 4. Previously firstPost.user's groups
97+
// were loaded twice – once by the relationship getter (visibleGroups) and
98+
// again by the email field's isAdmin() check (groups) – for 5 queries.
99+
$this->assertLessThanOrEqual(
100+
4,
101+
$count,
102+
"Discussion show issued $count `group_user` queries; expected at most 4. The extra query is the redundant per-user groups load from issue #4695."
103+
);
104+
}
105+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
/*
4+
* This file is part of Flarum.
5+
*
6+
* For detailed copyright and license information, please view the
7+
* LICENSE file that was distributed with this source code.
8+
*/
9+
10+
namespace Flarum\Tests\integration\api\posts;
11+
12+
use Carbon\Carbon;
13+
use Flarum\Discussion\Discussion;
14+
use Flarum\Group\Group;
15+
use Flarum\Post\Post;
16+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
17+
use Flarum\Testing\integration\TestCase;
18+
use Flarum\User\User;
19+
use PHPUnit\Framework\Attributes\Test;
20+
21+
/**
22+
* Regression test for the N+1 `group_user` queries described in
23+
* https://github.com/flarum/framework/issues/4695.
24+
*
25+
* The posts index includes `user.groups`. Before the fix, the UserResource
26+
* `groups` getter issued a fresh `group_user` query for every serialized post
27+
* author, so the query count grew linearly with the number of distinct authors.
28+
* After the fix the relation is eager-loaded once for the whole payload.
29+
*/
30+
class ListGroupsQueryCountTest extends TestCase
31+
{
32+
use RetrievesAuthorizedUsers;
33+
34+
/**
35+
* Number of distinct post authors. Deliberately large so that an N+1 would
36+
* produce many more `group_user` queries than the single batched load.
37+
*/
38+
private const AUTHOR_COUNT = 8;
39+
40+
protected function setUp(): void
41+
{
42+
parent::setUp();
43+
44+
$users = [];
45+
$posts = [];
46+
$groupUser = [];
47+
48+
for ($i = 0; $i < self::AUTHOR_COUNT; $i++) {
49+
$userId = 10 + $i;
50+
$users[] = [
51+
'id' => $userId,
52+
'username' => 'author'.$userId,
53+
'email' => 'author'.$userId.'@machine.local',
54+
'is_email_confirmed' => 1,
55+
'password' => 'foobar',
56+
];
57+
$posts[] = [
58+
'id' => 100 + $i,
59+
'number' => $i + 1,
60+
'discussion_id' => 1,
61+
'created_at' => Carbon::now(),
62+
'user_id' => $userId,
63+
'type' => 'comment',
64+
'content' => '<t><p>post by '.$userId.'</p></t>',
65+
];
66+
// Put every author in both a visible and a hidden group, so we also
67+
// exercise hidden-group filtering across many distinct users.
68+
$groupUser[] = ['user_id' => $userId, 'group_id' => 100];
69+
$groupUser[] = ['user_id' => $userId, 'group_id' => 101];
70+
}
71+
72+
$this->prepareDatabase([
73+
Discussion::class => [
74+
['id' => 1, 'title' => __CLASS__, 'created_at' => Carbon::now(), 'last_posted_at' => Carbon::now(), 'user_id' => 10, 'first_post_id' => 100, 'comment_count' => self::AUTHOR_COUNT],
75+
],
76+
Post::class => $posts,
77+
User::class => array_merge([$this->normalUser()], $users),
78+
Group::class => [
79+
['id' => 100, 'name_singular' => 'Visible', 'name_plural' => 'Visible', 'is_hidden' => 0],
80+
['id' => 101, 'name_singular' => 'Hidden', 'name_plural' => 'Hidden', 'is_hidden' => 1],
81+
],
82+
'group_user' => $groupUser,
83+
]);
84+
}
85+
86+
private function listPostsForDiscussion(): array
87+
{
88+
// Authenticate as the normal user (id 2), a non-admin who cannot view
89+
// hidden groups – this exercises both the groups relationship getter and
90+
// the email-field `editCredentials`/`isAdmin` path for every author.
91+
$response = $this->send(
92+
$this->request('GET', '/api/posts', ['authenticatedAs' => 2])
93+
->withQueryParams(['filter' => ['discussion' => 1]])
94+
);
95+
96+
$this->assertEquals(200, $response->getStatusCode());
97+
98+
return json_decode($response->getBody()->getContents(), true);
99+
}
100+
101+
#[Test]
102+
public function groups_are_batch_loaded_without_an_n_plus_one()
103+
{
104+
// Boot the app and populate the database before we start counting.
105+
$this->app();
106+
107+
$db = $this->database();
108+
$db->flushQueryLog();
109+
$db->enableQueryLog();
110+
111+
$this->listPostsForDiscussion();
112+
113+
// Classify every `group_user` query. The post authors' groups must be
114+
// loaded in one batched `where user_id in (...)` query – never one query
115+
// per author (which was the N+1). Loading the actor's own groups for
116+
// permission checks is constant overhead and unrelated.
117+
$batchedAuthorLoads = 0;
118+
$individualAuthorLoads = 0;
119+
120+
foreach ($db->getQueryLog() as $query) {
121+
if (stripos($query['query'], 'group_user') === false) {
122+
continue;
123+
}
124+
125+
if (stripos($query['query'], ' in (') !== false) {
126+
$batchedAuthorLoads++;
127+
continue;
128+
}
129+
130+
// A single-row `where user_id = ?` load – flag it if it targets one
131+
// of the post authors (ids >= 10) rather than the actor.
132+
foreach ($query['bindings'] as $binding) {
133+
if ((int) $binding >= 10) {
134+
$individualAuthorLoads++;
135+
}
136+
}
137+
}
138+
139+
$db->disableQueryLog();
140+
141+
$this->assertSame(
142+
0,
143+
$individualAuthorLoads,
144+
"Post authors' groups were loaded individually ($individualAuthorLoads times) instead of in a single batched query – this is the N+1 from issue #4695."
145+
);
146+
$this->assertGreaterThanOrEqual(
147+
1,
148+
$batchedAuthorLoads,
149+
'Expected the authors\' groups to be eager-loaded in a single batched query.'
150+
);
151+
}
152+
153+
#[Test]
154+
public function hidden_groups_are_still_filtered_for_many_users()
155+
{
156+
$body = $this->listPostsForDiscussion();
157+
158+
$groupIds = array_values(array_unique(array_map(
159+
fn (array $resource) => $resource['id'],
160+
array_filter($body['included'] ?? [], fn (array $r) => ($r['type'] ?? null) === 'groups')
161+
)));
162+
163+
// Group 101 is hidden and the actor (normal user, id 1) cannot view it.
164+
$this->assertContains('100', $groupIds);
165+
$this->assertNotContains('101', $groupIds);
166+
}
167+
}

0 commit comments

Comments
 (0)