Perf: customer press-releases-Liste ueber UNION statt OR-Paginate

Die Uebersicht /admin/me/press-releases war auch fuer leere Konten ~2s: der OR-Filter in scopeAccessibleBy zwang die Paginator-count(*) zum Full-Scan (EXPLAIN type=ALL, ~174k Zeilen) und den Zeilen-Fetch bei 0 Treffern zum vollen created_at-Index-Scan (~1s). Neue PressRelease::accessiblePageFor() nutzt dieselbe index-freundliche UNION wie accessibleStatusCountsFor(): schmale UNION liefert die Seiten-IDs ueber user_id-/company_id-Index, danach nur 25 Modelle hydratisiert; Total via schnellem UNION-count. Messung: leer ~2000ms -> 167ms, groesstes Konto (20.830 PMs) ~740ms -> 468ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-02 10:55:45 +00:00
parent 90f3245e41
commit db7c1dfbf1
3 changed files with 164 additions and 11 deletions

View file

@ -19,6 +19,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\HtmlString;
@ -287,6 +289,79 @@ class PressRelease extends Model
->all();
}
/**
* Seitenweise, für $user zugängliche PMs – über dieselbe index-freundliche
* UNION wie {@see accessibleStatusCountsFor()} statt des OR-Filters aus
* {@see scopeAccessibleBy()}.
*
* Warum nicht `accessibleBy()->paginate()`? Der OR-Ausdruck entartet bei
* beiden Extremen: bei leeren/sparsamen Treffermengen scannt der Zeilen-
* Fetch den created_at-Index vollständig (~1 s für 0 Treffer), und der
* Paginator-`count(*)` macht ohnehin einen Full-Scan (~1 s, EXPLAIN
* type=ALL). Stattdessen: schmale UNION (nur Sortierspalten) liefert die
* Seiten-IDs über die user_id-/company_id-Indizes, danach werden nur die
* 25 Modelle der Seite hydratisiert. Ergebnis: ~100 ms für normale, <500 ms
* für die größten Konten (Benchmark 02.07.).
*
* @param (Closure(Builder<self>): mixed)|null $constrain Filter, die auf
* beide UNION-Zweige angewandt werden (Firmen-/Status-/Portal-/Suchfilter)
* @return LengthAwarePaginator<self>
*/
public static function accessiblePageFor(
User $user,
?Closure $constrain = null,
string $sortColumn = 'created_at',
string $sortDir = 'desc',
int $perPage = 25,
): LengthAwarePaginator {
$sortColumn = in_array($sortColumn, ['created_at', 'title', 'status'], true) ? $sortColumn : 'created_at';
$sortDir = $sortDir === 'asc' ? 'asc' : 'desc';
$branch = function (Closure $where) use ($constrain): Builder {
$query = static::query()->withoutGlobalScopes()
->select('id', 'created_at', 'title', 'status');
$where($query);
if ($constrain !== null) {
$constrain($query);
}
return $query;
};
$authored = $branch(fn (Builder $q) => $q->where('user_id', $user->id));
$viaCompany = $branch(fn (Builder $q) => $q->whereIn(
'company_id',
Company::accessibleBy($user)->select('companies.id'),
));
$accessible = DB::query()->fromSub($authored->union($viaCompany), 'accessible_press_releases');
$page = Paginator::resolveCurrentPage();
$total = (clone $accessible)->count();
$pageIds = $accessible
->orderBy($sortColumn, $sortDir)
->orderBy('id', $sortDir)
->forPage($page, $perPage)
->pluck('id');
$models = static::query()->withoutGlobalScopes()
->whereKey($pageIds)
->with('company:id,name')
->orderBy($sortColumn, $sortDir)
->orderBy('id', $sortDir)
->get();
return new LengthAwarePaginator(
$models,
$total,
$perPage,
$page,
['path' => Paginator::resolveCurrentPath(), 'pageName' => 'page'],
);
}
/**
* Bewusst enger als {@see scopeAccessibleBy()}: Der API-Zugriff meint nur
* die eigene Autorschaft (user_id), nicht den breiteren Firmenzugriff.

View file

@ -135,17 +135,16 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
PressReleaseStatus::Archived->value => $statusCountsRaw[PressReleaseStatus::Archived->value] ?? 0,
];
$prs = PressRelease::accessibleBy($user)
->tap($applyCompanyFilter)
->with('company:id,name')
->when(filled($this->search), function ($q): void {
$term = $this->search;
$q->where('title', 'like', '%' . $term . '%');
})
->when($this->statusFilter !== 'all', fn($q) => $q->where('status', $this->statusFilter))
->when($this->portalFilter !== 'all', fn($q) => $q->where('portal', $this->portalFilter))
->orderBy(in_array($this->sortBy, ['title', 'status', 'created_at']) ? $this->sortBy : 'created_at', $this->sortDir)
->paginate(25);
// Der Firmen-Scope plus die Suchleiste/Status-/Portal-Filter werden in
// beide UNION-Zweige gezogen (siehe PressRelease::accessiblePageFor()).
$applyListFilters = function ($query) use ($applyCompanyFilter): void {
$applyCompanyFilter($query);
$query->when(filled($this->search), fn ($q) => $q->where('title', 'like', '%'.$this->search.'%'))
->when($this->statusFilter !== 'all', fn ($q) => $q->where('status', $this->statusFilter))
->when($this->portalFilter !== 'all', fn ($q) => $q->where('portal', $this->portalFilter));
};
$prs = PressRelease::accessiblePageFor($user, $applyListFilters, $this->sortBy, $this->sortDir);
return [
'pressReleases' => $prs,

View file

@ -0,0 +1,79 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\User;
test('includes authored and company-linked releases and dedupes overlap', function () {
$user = User::factory()->create();
$company = Company::factory()->create();
$user->companies()->attach($company->id, ['role' => 'member']);
$authored = PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => null]);
$viaCompany = PressRelease::factory()->create(['company_id' => $company->id]);
// erfüllt beide Zweige – darf nur einmal gezählt werden
$both = PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => $company->id]);
$page = PressRelease::accessiblePageFor($user);
expect($page->total())->toBe(3)
->and($page->pluck('id')->sort()->values()->all())
->toBe(collect([$authored->id, $viaCompany->id, $both->id])->sort()->values()->all());
});
test('excludes releases the user cannot access', function () {
$user = User::factory()->create();
$stranger = PressRelease::factory()->create();
$page = PressRelease::accessiblePageFor($user);
expect($page->total())->toBe(0)
->and($page->contains($stranger))->toBeFalse();
});
test('applies the constrain closure to both union branches', function () {
$user = User::factory()->create();
PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Published->value]);
PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]);
$page = PressRelease::accessiblePageFor(
$user,
fn ($query) => $query->where('status', PressReleaseStatus::Published->value),
);
expect($page->total())->toBe(1)
->and($page->first()->status)->toBe(PressReleaseStatus::Published);
});
test('respects sort column and direction', function () {
$user = User::factory()->create();
$a = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Alpha']);
$z = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Zulu']);
$asc = PressRelease::accessiblePageFor($user, null, 'title', 'asc');
$desc = PressRelease::accessiblePageFor($user, null, 'title', 'desc');
expect($asc->first()->id)->toBe($a->id)
->and($desc->first()->id)->toBe($z->id);
});
test('paginates with the given page size', function () {
$user = User::factory()->create();
PressRelease::factory()->count(7)->create(['user_id' => $user->id]);
$page = PressRelease::accessiblePageFor($user, null, 'created_at', 'desc', 5);
expect($page->total())->toBe(7)
->and($page->perPage())->toBe(5)
->and($page->count())->toBe(5);
});
test('falls back to created_at for an invalid sort column', function () {
$user = User::factory()->create();
PressRelease::factory()->create(['user_id' => $user->id]);
$page = PressRelease::accessiblePageFor($user, null, 'title; drop table', 'sideways');
expect($page->total())->toBe(1);
});