Fáze požadavku: 8. Databáze & Eloquent ORM
Modul 6 · Lekce 3

Plnění dat přes Seeders, Eager Loading with() a prevence problému N+1

💡 Shrnutí lekce

Jak efektivně plnit databázi výchozími daty pomocí Seederů a jak se vyhnout nejčastější výkonnostní pasti relačních databází: N+1 dotazům pomocí Eager Loadingu with().

Idempotentní Seeders a zabiják výkonu: Problém N+1 dotazů

Správa databázových dat zahrnuje dva klíčové koncepty: jak data spolehlivě do databáze dostat (Seeders) a jak je při čtení načítat tak, aby aplikace nezkolabovala pod tíhou stovek zbytečných SQL dotazů (Eager Loading).

V této lekci prozkoumáme kód našeho DatabaseSeeder a kontroléru CourseController.

1. Zlaté pravidlo Seederů: Vždy pište idempotentní kód

Při nasazení aplikace a v CI/CD pipelinách potřebujete spouštět seeder opakovaně bez obav, že databáze selže na chybě Integrity constraint violation: UNIQUE constraint failed.

Proto v našich seederech nepoužíváme CourseLesson::create([...]), ale metodu updateOrCreate():

$module = CourseModule::updateOrCreate(
    ['slug' => 'postgresql-migrace-a-eloquent-orm'], // Hledací kritérium
    [
        'number' => 6,
        'title' => '...',
        // ... Aktualizované atributy
    ]
);

Pokud modul se zadaným slug existuje, Eloquent jej pouze aktualizuje novými texty z kódu. Pokud neexistuje, vytvoří jej. Stejně tak u kvízových otázek nejprve provedeme $lesson->quizQuestions()->delete() a následně $lesson->quizQuestions()->createMany([...]). Seeder je tak 100% idempotentní – můžete jej spustit stokrát za sebou a výsledek bude vždy identický.

2. Noční můra vývojářů: N+1 Query Problem

Představte si, že v šabloně vypisujete přehled všech 10 modulů a u každého chcete zobrazit seznam jeho lekcí a u každé lekce počet kvízových otázek:

{{-- ❌ ŠPATNĚ: Typická N+1 past --}}
@foreach($modules as $module)
    <h2>{{ $module->title }}</h2>
    @foreach($module->lessons as $lesson)
        <p>{{ $lesson->title }} (otázek: {{ $lesson->quizQuestions->count() }})</p>
    @endforeach
@endforeach

Co se stane, pokud v controlleru načtete data prostým $modules = CourseModule::all();?

  1. Spustí se 1 dotaz na načtení modulů: SELECT * FROM course_modules; (vrátí např. 10 řádků).
  2. Při průchodu cyklem se pro každý modul spustí samostatný SQL dotaz: SELECT * FROM course_lessons WHERE course_module_id = 1; (10 dotazů).
  3. Uvnitř pro každou z celkových 30 lekcí se spustí další dotaz: SELECT * FROM quiz_questions WHERE course_lesson_id = 1; (30 dotazů).

Výsledek? Pro zobrazení jedné jednoduché stránky odeslala aplikace do PostgreSQL 41 samostatných SQL dotazů! Při větším počtu dat se aplikace fatálně zpomalí kvůli síťové latenci spojení s databází.

3. Řešení: Eager Loading s metodou with()

Eloquent nabízí elegantní řešení zvané Eager Loading (dopředné načtení). Místo líného dotazování v cyklu (Lazy Loading) řekneme Eloquentu předem, jaké vazby budeme potřebovat.

Podívejte se do našeho souboru app/Http/Controllers/CourseController.php:

public function index(Request $request, StudentProgressService $progressService): View
{
    $modules = CourseModule::with(['lessons.quizQuestions'])
        ->orderBy('number')
        ->get();

    // ...
}

Díky tečkové notaci lessons.quizQuestions v metodě with() Eloquent provede přesně 3 SQL dotazy:

  1. SELECT * FROM course_modules ORDER BY number;
  2. SELECT * FROM course_lessons WHERE course_module_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) ORDER BY number;
  3. SELECT * FROM quiz_questions WHERE course_lesson_id IN (1, 2, 3, ..., 30) ORDER BY position;

Ať už máte v databázi 10 nebo 10 000 lekcí, počet SQL dotazů zůstává konstantní: přesně 3. Eloquent načtené objekty sám v paměti pospojuje do příslušných relačních kolekcí.

💻 Klíčová konstrukce z praxe
// app/Http/Controllers/CourseController.php

public function index(
    Request $request,
    StudentProgressService $progressService,
    AnatomyInspectorService $inspectorService,
): View {
    // Eager Loading eliminuje N+1 problém:
    // Načte všechny moduly, lekce i jejich kvízy v POUHÝCH 3 SQL dotazech!
    $modules = CourseModule::with(['lessons.quizQuestions'])
        ->orderBy('number')
        ->get();

    $totalLessons = (int) $modules->sum(fn (CourseModule $m): int => $m->lessons->count());

    $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME);
    $student = $request->attributes->get('studentProfile');

    if (! ($student instanceof StudentProfile)) {
        $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null);
    }

    $stats = $progressService->getProgressStats($student, $totalLessons);

    return view('course.index', [
        'modules' => $modules,
        'stats' => $stats,
        'student' => $student,
        'lifecyclePhases' => LifecyclePhase::cases(),
    ]);
}

🎯 Ověření znalostí z lekce

Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.

Otázka 1 z 3

Co znamená pojem "N+1 query problem" v kontextu databázového ORM?

Otázka 2 z 3

Jak v Laravelu správně zapíšete Eager Loading pro vnořenou relaci (např. načtení lekcí i jejich otázek u modulu)?

Otázka 3 z 3

Proč je v databázových seederech klíčové používat metodu updateOrCreate() namísto create()?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: V app/Http/Controllers/CourseController.php si prohlédněte řádky 28–30 v metodě index(). Všimněte si zápisu with(['lessons.quizQuestions']). V database/seeders/DatabaseSeeder.php prozkoumejte postupné volání jednotlivých modulových seederů v metodě run().
cesta: database/seeders/DatabaseSeeder.php
45 řádků · 1.5 KB
Soubor aplikační architektury v modulu Database Seeder.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Database\Seeders;
6
7 use Database\Seeders\Curriculum\Module01ModernPhpSeeder;
8 use Database\Seeders\Curriculum\Module02AnatomyLifecycleSeeder;
9 use Database\Seeders\Curriculum\Module03RoutingControllersSeeder;
10 use Database\Seeders\Curriculum\Module04BladeFrontendSeeder;
11 use Database\Seeders\Curriculum\Module05LivewireReactiveSeeder;
12 use Database\Seeders\Curriculum\Module06DatabaseEloquentSeeder;
13 use Database\Seeders\Curriculum\Module07CleanArchitectureSeeder;
14 use Database\Seeders\Curriculum\Module08SecurityApiSeeder;
15 use Database\Seeders\Curriculum\Module09TestingArtisanSeeder;
16 use Database\Seeders\Curriculum\Module10DockerHetznerSeeder;
17 use Illuminate\Database\Seeder;
18
19 class DatabaseSeeder extends Seeder
20 {
21 /**
22 * Seed the application's database with all 10 course modules.
23 */
24 public function run(): void
25 {
26 $seeders = [
27 Module01ModernPhpSeeder::class,
28 Module02AnatomyLifecycleSeeder::class,
29 Module03RoutingControllersSeeder::class,
30 Module04BladeFrontendSeeder::class,
31 Module05LivewireReactiveSeeder::class,
32 Module06DatabaseEloquentSeeder::class,
33 Module07CleanArchitectureSeeder::class,
34 Module08SecurityApiSeeder::class,
35 Module09TestingArtisanSeeder::class,
36 Module10DockerHetznerSeeder::class,
37 ];
38
39 foreach ($seeders as $seederClass) {
40 if (class_exists($seederClass)) {
41 $this->call($seederClass);
42 }
43 }
44 }
45 }
46
cesta: app/Http/Controllers/CourseController.php
149 řádků · 4.9 KB
Resource controller obsluhující přehled modulů, detail lekce, markdown render a přepínání profilu studenta.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Http\Controllers;
6
7 use App\Enums\LifecyclePhase;
8 use App\Models\CourseLesson;
9 use App\Models\CourseModule;
10 use App\Models\StudentProfile;
11 use App\Services\AnatomyInspectorService;
12 use App\Services\StudentProgressService;
13 use Illuminate\Http\RedirectResponse;
14 use Illuminate\Http\Request;
15 use Illuminate\Support\Str;
16 use Illuminate\View\View;
17
18 final class CourseController
19 {
20 /**
21 * Zobrazí hlavní rozcestník a přehled všech 10 modulů kurzu.
22 */
23 public function index(
24 Request $request,
25 StudentProgressService $progressService,
26 AnatomyInspectorService $inspectorService,
27 ): View {
28 $modules = CourseModule::with(['lessons.quizQuestions'])
29 ->orderBy('number')
30 ->get();
31
32 $totalLessons = (int) $modules->sum(fn (CourseModule $m): int => $m->lessons->count());
33
34 $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME);
35 $student = $request->attributes->get('studentProfile');
36
37 if (! ($student instanceof StudentProfile)) {
38 $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null);
39 }
40
41 $stats = $progressService->getProgressStats($student, $totalLessons);
42
43 return view('course.index', [
44 'modules' => $modules,
45 'stats' => $stats,
46 'student' => $student,
47 'lifecyclePhases' => LifecyclePhase::cases(),
48 ]);
49 }
50
51 /**
52 * Zobrazí přehled konkrétního modulu a seznam jeho lekcí.
53 */
54 public function module(
55 Request $request,
56 CourseModule $module,
57 StudentProgressService $progressService,
58 ): View {
59 $module->load(['lessons.quizQuestions']);
60
61 $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME);
62 $student = $request->attributes->get('studentProfile');
63
64 if (! ($student instanceof StudentProfile)) {
65 $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null);
66 }
67
68 return view('course.module', [
69 'module' => $module,
70 'student' => $student,
71 ]);
72 }
73
74 /**
75 * Zobrazí detail vybrané lekce, vyrenderovaný Markdown, soubory anatomie a navigaci.
76 */
77 public function lesson(
78 Request $request,
79 CourseModule $module,
80 CourseLesson $lesson,
81 AnatomyInspectorService $inspectorService,
82 StudentProgressService $progressService,
83 ): View {
84 abort_if($lesson->course_module_id !== $module->id, 404);
85
86 $lesson->load(['quizQuestions', 'module.lessons']);
87
88 $renderedHtml = Str::markdown($lesson->content_markdown ?? '');
89
90 $anatomyNodes = $inspectorService->inspectMany($lesson->anatomy_files ?? []);
91
92 $orderedLessons = CourseLesson::query()
93 ->select('course_lessons.*')
94 ->join('course_modules', 'course_lessons.course_module_id', '=', 'course_modules.id')
95 ->with('module')
96 ->orderBy('course_modules.number')
97 ->orderBy('course_lessons.number')
98 ->get();
99
100 $currentIndex = $orderedLessons->search(fn (CourseLesson $item): bool => $item->id === $lesson->id);
101 $previousLesson = $currentIndex !== false && $currentIndex > 0 ? $orderedLessons->get($currentIndex - 1) : null;
102 $nextLesson = $currentIndex !== false && $currentIndex < $orderedLessons->count() - 1 ? $orderedLessons->get($currentIndex + 1) : null;
103
104 $rawToken = $request->cookie(StudentProgressService::COOKIE_NAME);
105 $student = $request->attributes->get('studentProfile');
106
107 if (! ($student instanceof StudentProfile)) {
108 $student = $progressService->resolveProfile(is_string($rawToken) ? $rawToken : null);
109 }
110
111 return view('course.lesson', compact(
112 'module',
113 'lesson',
114 'renderedHtml',
115 'anatomyNodes',
116 'previousLesson',
117 'nextLesson',
118 'student',
119 ));
120 }
121
122 /**
123 * Přepne identitu studenta na jiném zařízení pomocí přenosového kódu.
124 */
125 public function switchProfile(
126 Request $request,
127 StudentProgressService $progressService,
128 ): RedirectResponse {
129 /** @var array{transfer_code: string} $validated */
130 $validated = $request->validate([
131 'transfer_code' => ['required', 'string', 'max:24'],
132 ]);
133
134 $profile = $progressService->findByTransferCode($validated['transfer_code']);
135
136 if ($profile === null) {
137 return back()->with('error', 'Přenosový kód nebyl nalezen. Zkontroluj prosím jeho tvar.');
138 }
139
140 return redirect()
141 ->route('course.index')
142 ->with('success', 'Postup studenta byl úspěšně obnoven!')
143 ->withCookie(cookie(
144 name: StudentProgressService::COOKIE_NAME,
145 value: $profile->token,
146 minutes: 525600,
147 ));
148 }
149 }
150
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →