Perf: Status-Counts der PM-Uebersicht via UNION statt OR-GROUP-BY

Die nach Status gruppierte Zaehlung lief ueber accessibleBy() (user_id OR
company_id IN sub). Der OR-Ausdruck zwingt MySQL zu Full-Scans (EXPLAIN:
companies type=ALL, press_releases Full-Index-Scan ueber 124k Zeilen) und
war mit ~500-950ms der groesste Einzelposten der Seite.

Neue PressRelease::accessibleStatusCountsFor(User, ?Closure): kombiniert
Autor- und Firmen-Zweig als indexfreundliche UNION-Subquery (per PK
dedupliziert) und gruppiert darueber. Im Benchmark ~2,5x schneller bei
identischer Verteilung. Der Firmen-Filter der Uebersicht wird als geteilte
Closure auf Counts UND Liste angewandt (garantiert deckungsgleiche Filterung).

Gesamtmessung /admin/me/press-releases (User 14016, 26.772 Firmen / 20.830
PMs): Query-Pfad ~2850ms -> ~740ms warm; hydratisierte Company-Models 24.

Tests: accessibleStatusCountsFor zaehlt Autor+Firma je einmal (UNION-Dedup)
und wendet Zusatz-Constraints auf beide Zweige an.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-30 13:55:13 +00:00
parent 07d98ed79e
commit 580c918ad3
3 changed files with 99 additions and 14 deletions

View file

@ -10,6 +10,7 @@ use App\Enums\PressReleaseStatus;
use App\Models\Concerns\HasUniqueSlug;
use App\Scopes\PortalScope;
use App\Services\PressRelease\PressReleaseHtmlSanitizer;
use Closure;
use Database\Factories\PressReleaseFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -19,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\HtmlString;
class PressRelease extends Model
@ -242,6 +244,49 @@ class PressRelease extends Model
});
}
/**
* Nach Status gruppierte Anzahl der für $user zugänglichen PMs.
*
* Bewusst als UNION zweier indexfreundlicher Zweige (Autor über user_id,
* Firmenzugriff über den company_id-Semi-Join) statt des OR-Filters aus
* {@see scopeAccessibleBy()}: Der OR-Ausdruck zwingt MySQL bei Power-Usern
* zu Full-Scans (companies type=ALL); die UNION-Variante ist im Benchmark
* ~2,5x schneller bei identischem Ergebnis. Die UNION dedupliziert über die
* PK, sodass eine PM, die beide Zweige erfüllt, nur einmal zählt.
*
* @param (Closure(Builder<self>): mixed)|null $constrain zusätzliche
* Einschränkung, die auf beide Zweige angewandt wird (z. B. der
* Firmen-Filter der Übersicht)
* @return array<string, int> status-Wert => Anzahl
*/
public static function accessibleStatusCountsFor(User $user, ?Closure $constrain = null): array
{
$branch = function (Closure $where) use ($constrain): Builder {
$query = static::query()->withoutGlobalScopes()->select('id', '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'),
));
return DB::query()
->fromSub($authored->union($viaCompany), 'accessible_press_releases')
->selectRaw('status, COUNT(*) as aggregate')
->groupBy('status')
->pluck('aggregate', 'status')
->map(fn ($count): int => (int) $count)
->all();
}
/**
* Bewusst enger als {@see scopeAccessibleBy()}: Der API-Zugriff meint nur
* die eigene Autorschaft (user_id), nicht den breiteren Firmenzugriff.

View file

@ -111,26 +111,32 @@ new #[Layout('components.layouts.app'), Title('Meine Pressemitteilungen')] class
public function with(): array
{
$context = app(CustomerCompanyContext::class);
$selectedCompanyId = $context->selectedCompanyId(auth()->user());
$user = auth()->user();
$selectedCompanyId = $context->selectedCompanyId($user);
// Firmen-Scope: eigene PMs (Autor) ODER PMs der zugeordneten Firmen.
$base = PressRelease::accessibleBy(auth()->user())
->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));
// Firmen-Scope der Übersicht: auf eine gewählte Firma einschränken bzw.
// (im globalen Kontext) auf zugeordnete/unzugeordnete PMs. Wird sowohl
// auf die Status-Counts als auch die Liste angewandt, damit beide
// deckungsgleich filtern.
$applyCompanyFilter = function ($query) use ($selectedCompanyId): void {
$query->when($selectedCompanyId !== null, fn($q) => $q->where('company_id', $selectedCompanyId))
->when($selectedCompanyId === null && $this->companyFilter === 'assigned', fn($q) => $q->whereNotNull('company_id'))
->when($selectedCompanyId === null && $this->companyFilter === 'unassigned', fn($q) => $q->whereNull('company_id'));
};
$statusCountsRaw = (clone $base)->toBase()->selectRaw('status, COUNT(*) as aggregate')->groupBy('status')->pluck('aggregate', 'status');
$statusCountsRaw = PressRelease::accessibleStatusCountsFor($user, $applyCompanyFilter);
$statusCounts = [
'all' => (int) $statusCountsRaw->sum(),
PressReleaseStatus::Published->value => (int) ($statusCountsRaw[PressReleaseStatus::Published->value] ?? 0),
PressReleaseStatus::Draft->value => (int) ($statusCountsRaw[PressReleaseStatus::Draft->value] ?? 0),
PressReleaseStatus::Review->value => (int) ($statusCountsRaw[PressReleaseStatus::Review->value] ?? 0),
PressReleaseStatus::Rejected->value => (int) ($statusCountsRaw[PressReleaseStatus::Rejected->value] ?? 0),
PressReleaseStatus::Archived->value => (int) ($statusCountsRaw[PressReleaseStatus::Archived->value] ?? 0),
'all' => array_sum($statusCountsRaw),
PressReleaseStatus::Published->value => $statusCountsRaw[PressReleaseStatus::Published->value] ?? 0,
PressReleaseStatus::Draft->value => $statusCountsRaw[PressReleaseStatus::Draft->value] ?? 0,
PressReleaseStatus::Review->value => $statusCountsRaw[PressReleaseStatus::Review->value] ?? 0,
PressReleaseStatus::Rejected->value => $statusCountsRaw[PressReleaseStatus::Rejected->value] ?? 0,
PressReleaseStatus::Archived->value => $statusCountsRaw[PressReleaseStatus::Archived->value] ?? 0,
];
$prs = (clone $base)
$prs = PressRelease::accessibleBy($user)
->tap($applyCompanyFilter)
->with('company:id,name')
->when(filled($this->search), function ($q): void {
$term = $this->search;

View file

@ -67,6 +67,40 @@ test('accessibleBy excludes press releases of strangers and unrelated companies'
expect($ids)->not->toContain($foreignPr->id);
});
test('accessibleStatusCountsFor counts authored and company PMs once each', function () {
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
$stranger = User::factory()->create();
PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => null, 'status' => 'draft']);
PressRelease::factory()->create(['user_id' => $stranger->id, 'company_id' => $company->id, 'status' => 'published', 'portal' => $company->portal->value]);
// Autor UND zugeordnete Firma: darf nur einmal zählen (UNION-Dedup).
PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => $company->id, 'status' => 'published', 'portal' => $company->portal->value]);
// Fremde PM ohne Bezug: ausgeschlossen.
PressRelease::factory()->create(['user_id' => $stranger->id, 'company_id' => null, 'status' => 'draft']);
$counts = PressRelease::accessibleStatusCountsFor($user);
expect($counts['draft'] ?? 0)->toBe(1)
->and($counts['published'] ?? 0)->toBe(2)
->and(array_sum($counts))->toBe(3);
});
test('accessibleStatusCountsFor applies the additional constraint to both branches', function () {
$user = User::factory()->create();
$company = Company::factory()->presseecho()->create();
$user->companies()->attach($company->id, ['role' => 'owner']);
PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => null, 'status' => 'draft']);
PressRelease::factory()->create(['user_id' => $user->id, 'company_id' => $company->id, 'status' => 'draft', 'portal' => $company->portal->value]);
$unassigned = PressRelease::accessibleStatusCountsFor($user, fn ($q) => $q->whereNull('company_id'));
expect(array_sum($unassigned))->toBe(1)
->and($unassigned['draft'] ?? 0)->toBe(1);
});
test('accessibleCompanyIds returns owned and member company ids without duplicates', function () {
$user = User::factory()->create();
$owned = Company::factory()->create(['owner_user_id' => $user->id]);