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:
parent
90f3245e41
commit
db7c1dfbf1
3 changed files with 164 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
79
tests/Feature/PressReleaseAccessiblePageTest.php
Normal file
79
tests/Feature/PressReleaseAccessiblePageTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue