Skip to content

Commit c50c9ec

Browse files
authored
perf: materialize post visibility once; flat grouped relation aggregates (#4866)
Two structural fixes to how post visibility interacts with the query shapes the API layer generates: ScopePostVisibility embedded its discussion-level checks as per-row correlated EXISTS subqueries. Many posts share few discussions, so the compounded discussion visibility conditions were re-evaluated for every candidate row — on a post mentioned 1.4k times, 1,403 evaluations to check 2 discussions. Both checks are now uncorrelated IN-subqueries the database can materialize once per query. Extension scopers compose exactly as before: their logic lives inside Discussion::whereVisibleTo, which is unchanged. EloquentBuffer loaded relation aggregates (countRelation etc. — used by mentions, likes and messages) through Laravel's loadAggregate, which emits a correlated scalar subquery per model and forces the aggregate's constraints — typically the full visibility scope — to re-run per related row PER MODEL, defeating the materialization above. Aggregates for all buffered models are now computed by ONE flat grouped query built from the relation's own eager constraints, with loadAggregate as the fallback for relation types without well-known key semantics. The mentionedByCount batch on a post mentioned 1,403 times drops from 5.5ms to 1.75ms; the discussion-level visibility semantics are pinned by new characterization tests green on both implementations.
1 parent f8d271b commit c50c9ec

3 files changed

Lines changed: 221 additions & 10 deletions

File tree

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
use Illuminate\Database\Eloquent\Builder;
1515
use Illuminate\Database\Eloquent\Collection;
1616
use Illuminate\Database\Eloquent\Model;
17+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
18+
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
1719
use Illuminate\Database\Eloquent\Relations\MorphTo;
1820
use Illuminate\Database\Eloquent\Relations\Relation;
1921
use Illuminate\Support\Str;
@@ -148,8 +150,77 @@ public static function load(
148150
}
149151
});
150152
} else {
151-
$alias = Str::snake($aggregate['name']);
153+
self::loadAggregate($collection, $relationName, $aggregate, $loader);
154+
}
155+
}
156+
157+
/**
158+
* Load a relation aggregate for all buffered models with ONE flat grouped
159+
* query.
160+
*
161+
* Laravel's loadAggregate() emits a correlated scalar subquery per model
162+
* (`select id, (select count(*) ... where outer.id = ...)`), which forces
163+
* the database to re-evaluate the aggregate's constraints — for API
164+
* resources, typically the full visibility scope — once per related row
165+
* PER MODEL, with no chance to materialize shared subqueries. A grouped
166+
* join over the relation's eager constraints computes every model's
167+
* aggregate in a single flat pass instead.
168+
*
169+
* @param array{name: string, relation: string, column: string, function: string, constrain: callable|null} $aggregate
170+
*/
171+
protected static function loadAggregate(Collection $collection, string $relationName, array $aggregate, callable $loader): void
172+
{
173+
$alias = Str::snake($aggregate['name']);
174+
175+
/** @var Model $parent */
176+
$parent = $collection->first();
177+
178+
/** @var Relation $relation */
179+
$relation = Relation::noConstraints(fn () => $parent->newModelInstance()->{$relationName}());
180+
181+
// The key the related rows are grouped and matched back to parents
182+
// by, exactly as eager loading would match them.
183+
[$groupColumn, $parentKeyName] = match (true) {
184+
$relation instanceof BelongsToMany => [$relation->getQualifiedForeignPivotKeyName(), $relation->getParentKeyName()],
185+
$relation instanceof HasOneOrMany => [$relation->getQualifiedForeignKeyName(), $relation->getLocalKeyName()],
186+
default => [null, null],
187+
};
188+
189+
if ($groupColumn === null) {
190+
// Unsupported relation type: fall back to Laravel's per-model
191+
// correlated subqueries rather than guessing at key semantics.
152192
$collection->loadAggregate(["$relationName as $alias" => $loader], $aggregate['column'], $aggregate['function']);
193+
194+
return;
195+
}
196+
197+
$relation->addEagerConstraints($collection->all());
198+
199+
// Applies the aggregate's constrain callback (e.g. visibility
200+
// scoping) to the relation's underlying Eloquent builder.
201+
$loader($relation);
202+
203+
$query = $relation->getQuery();
204+
$grammar = $query->getQuery()->getGrammar();
205+
206+
$column = $aggregate['column'] === '*'
207+
? '*'
208+
: $grammar->wrap($relation->getRelated()->qualifyColumn($aggregate['column']));
209+
210+
$results = $query
211+
->toBase()
212+
->select($groupColumn)
213+
->selectRaw("{$aggregate['function']}({$column}) as {$grammar->wrap($alias)}")
214+
->groupBy($groupColumn)
215+
->get()
216+
->keyBy(Str::afterLast($groupColumn, '.'));
217+
218+
$default = $aggregate['function'] === 'count' ? 0 : null;
219+
220+
foreach ($collection as $model) {
221+
$value = $results[$model->getAttribute($parentKeyName)]->{$alias} ?? $default;
222+
223+
$model->setAttribute($alias, $value);
153224
}
154225
}
155226
}

framework/core/src/Post/Access/ScopePostVisibility.php

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,15 @@ public function withinDiscussion(User $actor, Builder $query, Discussion $discus
4747

4848
public function __invoke(User $actor, Builder $query): void
4949
{
50-
// Make sure the post's discussion is visible as well.
51-
$query->whereExists(function ($query) use ($actor) {
52-
$query->selectRaw('1')
53-
->from('discussions')
54-
->whereColumn('discussions.id', 'posts.discussion_id');
50+
// Make sure the post's discussion is visible as well. Shaped as an
51+
// uncorrelated IN-subquery rather than a per-row EXISTS: the visible
52+
// discussions can then be materialized once per query, instead of the
53+
// discussion visibility conditions (which extensions compound) being
54+
// re-evaluated for every candidate post row — many posts share few
55+
// discussions, so on mention counts, post windows and search results
56+
// the EXISTS form did the same work hundreds of times over.
57+
$query->whereIn('posts.discussion_id', function ($query) use ($actor) {
58+
$query->select('discussions.id')->from('discussions');
5559
Discussion::query()->setQuery($query)->whereVisibleTo($actor);
5660
});
5761

@@ -69,11 +73,12 @@ public function __invoke(User $actor, Builder $query): void
6973
if (! $actor->hasPermission('discussion.hidePosts')) {
7074
$query->where(function ($query) use ($actor) {
7175
$query->whereNull('posts.hidden_at')
72-
->orWhere('posts.user_id', $actor->id)
73-
->orWhereExists(function ($query) use ($actor) {
74-
$query->selectRaw('1')
76+
->orWhere('posts.user_id', $actor->id)
77+
->orWhereIn('posts.discussion_id', function ($query) use ($actor) {
78+
$query->select('discussions.id')
7579
->from('discussions')
76-
->whereColumn('discussions.id', 'posts.discussion_id')
80+
// The 1=0 seed keeps this subquery empty when no
81+
// scoper grants the hidePosts ability at all.
7782
->where(function ($query) use ($actor) {
7883
$query
7984
->whereRaw('1=0')
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
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\Extend;
15+
use Flarum\Post\Post;
16+
use Flarum\Testing\integration\RetrievesAuthorizedUsers;
17+
use Flarum\Testing\integration\TestCase;
18+
use Flarum\User\User;
19+
use Illuminate\Database\Eloquent\Builder;
20+
use Illuminate\Support\Arr;
21+
use PHPUnit\Framework\Attributes\Test;
22+
23+
/**
24+
* Characterizes ScopePostVisibility's discussion-level branches — the
25+
* discussion must be visible, and hidden posts show for actors granted the
26+
* hidePosts ability on the post's discussion — so the SQL shape (per-row
27+
* EXISTS vs materialized IN-subquery) can change without the semantics
28+
* moving. The post-local branches (own hidden posts, private posts) are
29+
* covered by ShowTest and PostIdsVisibilityTest.
30+
*/
31+
class PostVisibilityTest extends TestCase
32+
{
33+
use RetrievesAuthorizedUsers;
34+
35+
protected function setUp(): void
36+
{
37+
parent::setUp();
38+
39+
$this->prepareDatabase([
40+
Discussion::class => [
41+
['id' => 1, 'title' => 'Public', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 10, 'comment_count' => 2, 'is_private' => 0],
42+
['id' => 2, 'title' => 'Private', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 20, 'comment_count' => 1, 'is_private' => 1],
43+
['id' => 3, 'title' => 'Soft-deleted', 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'first_post_id' => 30, 'comment_count' => 1, 'is_private' => 0, 'hidden_at' => Carbon::now()->toDateTimeString()],
44+
],
45+
Post::class => [
46+
['id' => 10, 'discussion_id' => 1, 'number' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>public post</p></t>'],
47+
['id' => 11, 'discussion_id' => 1, 'number' => 2, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>hidden post</p></t>', 'hidden_at' => Carbon::now()->toDateTimeString()],
48+
['id' => 20, 'discussion_id' => 2, 'number' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>post in private discussion</p></t>'],
49+
['id' => 30, 'discussion_id' => 3, 'number' => 1, 'created_at' => Carbon::now()->toDateTimeString(), 'user_id' => 2, 'type' => 'comment', 'content' => '<t><p>post in soft-deleted discussion</p></t>'],
50+
],
51+
User::class => [
52+
$this->normalUser(),
53+
['id' => 3, 'username' => 'onlooker', 'email' => 'onlooker@machine.local', 'is_email_confirmed' => 1],
54+
],
55+
]);
56+
}
57+
58+
protected function tearDown(): void
59+
{
60+
// Scopers registered through test extenders live in static model
61+
// state and would leak into later tests in this process.
62+
(function () {
63+
self::$visibilityScopers = [];
64+
})->bindTo(null, Discussion::class)();
65+
66+
parent::tearDown();
67+
}
68+
69+
private function visiblePostIds(?int $actorId = null): array
70+
{
71+
$response = $this->send(
72+
$this->request('GET', '/api/posts', $actorId ? ['authenticatedAs' => $actorId] : [])
73+
);
74+
75+
$this->assertEquals(200, $response->getStatusCode());
76+
77+
$json = json_decode($response->getBody()->getContents(), true);
78+
79+
$ids = array_map('intval', array_column(Arr::get($json, 'data', []), 'id'));
80+
sort($ids);
81+
82+
return $ids;
83+
}
84+
85+
#[Test]
86+
public function posts_of_invisible_discussions_are_excluded_for_guests()
87+
{
88+
// Private (2) and soft-deleted (3) discussions are invisible, so their
89+
// posts never appear; the hidden post (11) is excluded post-locally.
90+
$this->assertSame([10], $this->visiblePostIds());
91+
}
92+
93+
#[Test]
94+
public function discussion_author_sees_posts_of_their_soft_deleted_discussion()
95+
{
96+
// ScopeDiscussionVisibility lets authors see their own hidden
97+
// discussions, and post visibility must follow it.
98+
$this->assertSame([10, 11, 30], $this->visiblePostIds(2));
99+
}
100+
101+
#[Test]
102+
public function unrelated_user_sees_only_public_discussion_posts()
103+
{
104+
$this->assertSame([10], $this->visiblePostIds(3));
105+
}
106+
107+
#[Test]
108+
public function hidden_posts_appear_when_a_scoper_grants_hide_posts_on_the_discussion()
109+
{
110+
// Grants the hidePosts ability on discussion 1 only — like a per-tag
111+
// moderator. User 3 gains the hidden post there, and nothing else.
112+
$this->extend(
113+
(new Extend\ModelVisibility(Discussion::class))
114+
->scope(GrantHidePostsOnDiscussionOne::class, 'hidePosts')
115+
);
116+
117+
$this->assertSame([10, 11], $this->visiblePostIds(3));
118+
}
119+
120+
#[Test]
121+
public function without_any_hide_posts_scoper_no_discussion_grants_hidden_posts()
122+
{
123+
// The ability subquery must stay EMPTY when nothing scopes it — a
124+
// seeded 1=0 guards against an unconstrained "all discussions" set.
125+
$this->assertSame([10], $this->visiblePostIds(3));
126+
}
127+
}
128+
129+
class GrantHidePostsOnDiscussionOne
130+
{
131+
public function __invoke(User $actor, Builder $query): void
132+
{
133+
$query->orWhere('discussions.id', 1);
134+
}
135+
}

0 commit comments

Comments
 (0)