18. PHP – łączenie z bazą i operacje SELECT


Szybki quiz – przypomnienie poprzedniej lekcji

Lekcja 17: bazy danych, mysqli i PDO, projekt tabel TaskQuest. Odpowiedz z pamięci.

  1. Masz projekt, który być może zostanie przeniesiony z MySQL na PostgreSQL. Które rozszerzenie wybierzesz i dlaczego?

  2. Co w tej definicji jest niepoprawne i jaki będzie skutek?CREATE TABLE zadania (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    tytul VARCHAR(100) NOT NULL,
    xp INT UNSIGNED NOT NULL DEFAULT 0,
    ) ENGINE=InnoDB;

  3. Kolumna login ma NOT NULL UNIQUE. Które z tych operacji baza odrzuci?
    a) dodanie użytkownika bez podania loginu,
    b) dodanie drugiego użytkownika o loginie Ola_TQ,
    c) dodanie użytkownika z loginem ola_tq, gdy istnieje Ola_TQ.

  4. Dlaczego kolumna uzytkownik_id jest w tabeli zadania, a nie kolumna zadania_id w tabeli uzytkownicy?

  5. Uczeń chce dodać do tabeli uzytkownicy kolumnę poziom, bo „łatwiej będzie ją wyświetlić”. Co mu odpowiesz i na jaką zasadę się powołasz?

  6. Dwa z tych wierszy INSERT nie zapiszą tego, czego oczekuje autor. Które i dlaczego?INSERT INTO uzytkownicy (login, imie, xp, data_ostatniej_aktywnosci) VALUES
    ('Kama_2026', 'Kama', 99, '2026-09-14'),
    ('Ola_TQ', 'Ola', '250', 'NULL'),
    ('Bartek_9', 'Bartek', 0, '01.09.2026');

  7. Baza ma kodowanie utf8, a nie utf8mb4. Kiedy zobaczysz problem, a kiedy nie?

Odpowiedzi
  1. PDO — jako jedyne daje ten sam interfejs dla MySQL, PostgreSQL, SQLite i innych silników, więc zmiana bazy nie wymaga przepisywania kodu. mysqli działa wyłącznie z MySQL/MariaDB.
  2. Przecinek po ostatniej kolumnie (DEFAULT 0,) — błąd składni #1064, tabela w ogóle nie powstanie.
  3. a) i b). Przypadek c) przejdzie lub nie w zależności od porównywania wielkości liter przez zestaw znaków, ale na domyślnym utf8mb4_unicode_ci wielkość liter nie jest rozróżniana, więc baza też go odrzuci.
  4. Bo w relacji 1:N klucz obcy trafia zawsze po stronie „N”. Jedno pole mieści jedną wartość — nie zapiszesz w nim listy „zadania 1, 2, 3”.
  5. Nie dodajemy, bo poziom da się obliczyć z XP. Zasada: w bazie trzymamy tylko dane, których nie da się wyliczyć z innych danych — inaczej przy xp = 350, poziom = 2 nie wiadomo, która wartość kłamie. Poziom liczy obliczPoziom() / metoda klasy Uzytkownik.
  6. Wiersz Oli: 'NULL' w apostrofach zapisze tekst „NULL”, a nie brak wartości ('250' zadziała, bo baza przekonwertuje tekst na liczbę, ale to zła praktyka). Wiersz Bartka: data '01.09.2026' — baza przyjmuje wyłącznie format RRRR-MM-DD.
  7. Polskie litery zapiszą się poprawnie, bo mieszczą się w utf8. Problem pojawi się dopiero przy emoji (4-bajtowe znaki) — wtedy błąd zapisu albo obcięte dane.

TaskQuest

Dodatek

Bazy danych to fundament większości aplikacji webowych. Za każdym razem gdy:

  • Przeglądasz posty na Instagramie
  • Sprawdzasz ranking w grze online
  • Szukasz wiadomości na Discordzie
  • Oglądasz playlistę na Spotify

W tym materiale nauczysz się pobierać dane z bazy MySQL/MariaDB używając PHP. To podstawowa umiejętność, którą musisz opanować przed nauką modyfikowania danych (INSERT, UPDATE, DELETE – te operacje poznasz w następnym temacie).

Dane dostępowe do bazy

Niezależnie od wybranej metody, potrzebujesz 4 podstawowych informacji:

$serwer = 'localhost';       // Adres serwera (localhost lub 127.0.0.1)
$user = 'root';              // Nazwa użytkownika bazy
$pass = '';                  // Hasło (domyślnie puste w XAMPP)
$baza = 'nazwa_bazy';        // Nazwa Twojej bazy danych

UWAGA: W środowisku produkcyjnym (na prawdziwym serwerze):

  • Hasło NIE MOŻE być puste!
  • Użytkownik NIE POWINIEN być root
  • Dane logowania przechowuj w oddzielnym pliku (np. config.php)
  • Nigdy nie commituj danych dostępowych do GitHuba!

Przykład pliku config.php:

<?php
// config.php - NIE COMMITUJ tego pliku do repozytorium!

define('DB_HOST', 'localhost');
define('DB_USER', 'twoj_user');
define('DB_PASS', 'twoje_silne_haslo');
define('DB_NAME', 'nazwa_bazy');
?>

Użycie w kodzie PDO:

<?php
require_once 'config.php';

... łączenie z bazą ...
?>

Przygotowanie – Utworzenie przykładowej bazy

Przed rozpoczęciem nauki potrzebujesz bazy z przykładowymi danymi. Stworzymy bazę gaming_hub z tabelą graczy – będzie bardziej interesująca niż klasyczne „imię i nazwisko”

Krok 1: Otwórz phpMyAdmin

W przeglądarce wejdź na:

http://localhost/phpmyadmin

Krok 2: Utwórz nową bazę danych

  • Kliknij zakładkę „Bazy danych”
  • W polu „Utwórz bazę danych” wpisz: gaming_hub
  • Wybierz kodowanie: utf8mb4_unicode_ci (poprawniejsze sortowanie wielu języków, lepsza zgodność Unicode, obsługa polskich znaków; mb4 w utf8mb4 oznacza „maximum bytes 4” — czyli kodowanie UTF-8 obsługujące znaki zajmujące do 4 bajtów.)
  • Kliknij „Utwórz”

Krok 3: Utwórz tabelę z danymi

Kliknij na bazę gaming_hub, przejdź do zakładki SQL i wykonaj poniższy kod:

-- Tabela graczy
CREATE TABLE players (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100),
    level INT DEFAULT 1,
    coins INT DEFAULT 0,
    rank_name VARCHAR(20) DEFAULT 'Bronze',
    games_played INT DEFAULT 0,
    wins INT DEFAULT 0,
    losses INT DEFAULT 0,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- Przykładowe dane graczy (15 rekordów)
INSERT INTO players (username, email, level, coins, rank_name, games_played, wins, losses, last_login) VALUES
('ProGamer_Mike', 'mike@example.com', 45, 12500, 'Diamond', 850, 520, 330, NOW()),
('xX_Anna_Xx', 'anna@example.com', 32, 8900, 'Platinum', 620, 380, 240, NOW()),
('NinjaKiller', 'ninja@example.com', 67, 23400, 'Master', 1240, 890, 350, NOW()),
('ShadowHunter', 'shadow@example.com', 28, 6700, 'Gold', 490, 270, 220, DATE_SUB(NOW(), INTERVAL 2 DAY)),
('DragonSlayer99', 'dragon@example.com', 19, 4200, 'Silver', 320, 160, 160, DATE_SUB(NOW(), INTERVAL 5 DAY)),
('CoolGamer', 'cool@example.com', 52, 15600, 'Diamond', 980, 610, 370, NOW()),
('LegendaryPlayer', 'legend@example.com', 89, 45000, 'Grandmaster', 2100, 1560, 540, NOW()),
('NoobMaster', 'noob@example.com', 8, 1200, 'Bronze', 85, 30, 55, DATE_SUB(NOW(), INTERVAL 10 DAY)),
('FastFingers', 'fast@example.com', 41, 11200, 'Platinum', 740, 450, 290, NOW()),
('SilentAssassin', 'silent@example.com', 36, 9800, 'Platinum', 680, 410, 270, DATE_SUB(NOW(), INTERVAL 1 DAY)),
('ThunderStrike', 'thunder@example.com', 24, 5600, 'Gold', 410, 220, 190, DATE_SUB(NOW(), INTERVAL 3 DAY)),
('IceQueen', 'ice@example.com', 55, 17800, 'Diamond', 1050, 670, 380, NOW()),
('FireStorm', 'fire@example.com', 15, 3100, 'Silver', 250, 120, 130, DATE_SUB(NOW(), INTERVAL 7 DAY)),
('MysticWarrior', 'mystic@example.com', 63, 21000, 'Master', 1180, 800, 380, NOW()),
('SpeedDemon', 'speed@example.com', 29, 7200, 'Gold', 520, 290, 230, DATE_SUB(NOW(), INTERVAL 4 DAY));

Struktura tabeli players:

KolumnaTypOpis
idINTUnikalny identyfikator (AUTO_INCREMENT)
usernameVARCHAR(50)Nick gracza (unikalny)
emailVARCHAR(100)Adres email
levelINTPoziom gracza
coinsINTWirtualna waluta
rank_nameVARCHAR(20)Ranga (Bronze, Silver, Gold, Platinum, Diamond, Master, Grandmaster)
games_playedINTLiczba rozegranych gier
winsINTLiczba wygranych
lossesINTLiczba przegranych
created_atTIMESTAMPData utworzenia konta
last_loginTIMESTAMPOstatnie logowanie

Sposoby połączenia z bazą danych

PHP oferuje 2 nowoczesne sposoby komunikacji z MySQL, podejście proceduralne pomijamy jako przestarzałe.

1. MySQLi – Podejście obiektowe

Wykorzystujemy klasę mysqli.

$db = new mysqli($serwer, $user, $pass, $baza);

Podejście dobre, ale tylko dla MySQL/MariaDB

2. PDO – PHP Data Objects (ZALECANE!)

$pdo = new PDO("mysql:host=$serwer;dbname=$baza;charset=utf8mb4", $user, $pass);

Podejście uniwersalne, bezpieczne, nowoczesne

Łączenie z bazą danych

MySQLi – Podejście obiektowe

<?php
// Dane dostępowe
$serwer = 'localhost';
$user = 'root';
$pass = '';
$baza = 'gaming_hub';

// Wyłączamy automatyczne raportowanie błędów
mysqli_report(MYSQLI_REPORT_OFF);

// Połączenie obiektowe (@ ukrywa ostrzeżenia)
$db = @new mysqli($serwer, $user, $pass, $baza);

// Sprawdzenie połączenia
if ($db->connect_error) {
    die("Błąd połączenia: {$db->connect_error}");
}

echo 'Połączenie nawiązano!<br>';

// Ustawienie kodowania (ważne dla polskich znaków i emoji!)
$db->set_charset('utf8mb4');

// ... wykonywanie zapytań ...

// Zamknięcie połączenia
$db->close();
?>

Dostępne właściwości do obsługi błędów:

$db->connect_error   // Opis błędu połączenia
$db->connect_errno   // Kod błędu połączenia
$db->error           // Opis ostatniego błędu
$db->errno           // Kod ostatniego błędu

PDO – PHP Data Objects

<?php
// Dane dostępowe
$serwer = 'localhost';
$user = 'root';
$pass = '';
$baza = 'gaming_hub';

// Opcje PDO dla bezpieczeństwa i wygody
$opcje = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,        // Włącz wyjątki
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,   // Domyślnie tablica asocjacyjna
    PDO::ATTR_EMULATE_PREPARES => false,                // Prawdziwe prepared statements
];

try {
    // Połączenie PDO
    $pdo = new PDO(
        "mysql:host=$serwer;dbname=$baza;charset=utf8mb4",
        $user,
        $pass,
        $opcje
    );
    
    echo 'Połączenie nawiązano!<br>';
    
    // ... wykonywanie zapytań ...
    
} catch (PDOException $e) {
    // W prawdziwej aplikacji NIE pokazuj szczegółów błędu użytkownikowi!
    error_log("DB Error: {$e->getMessage()}");
    die('Nie można połączyć się z bazą danych. Spróbuj później.');
}

// Zamknięcie połączenia
$pdo = null;
?>
  • PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION – rzuca wyjątki przy błędach (łatwiej debugować)
  • PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC – zwraca tablice asocjacyjne (czytelniejsze)
  • PDO::ATTR_EMULATE_PREPARES => false – prawdziwe prepared statements (bezpieczniejsze)

Zamykanie połączenia z bazą

Operacja zamknięcia połączenia jest automatycznie wykonywana po zakończeniu skryptu. Jeżeli potrzebujemy zakończyć połączenie wcześniej:

MySQLi obiektowo:

$db->close();

PDO:

$pdo = null;
// lub
unset($pdo);

W praktyce: Rzadko musisz ręcznie zamykać połączenie – PHP robi to automatycznie po zakończeniu skryptu, ale na egzaminie zawodowym jest to wymagane.

SELECT – Pobieranie wszystkich rekordów

UWAGA: stosowanie query() nie zapewnia bezpieczeństwa w przypadku ataków SQJ Injection. Zamiast query należy używać parameterized prepared statements (opracowanie dalej w temacie). Aby query() było bezpieczne należy stosować mysqli_real_escape_string().

Użycie samego query() jest dopuszczalne na egzaminie zawodowym, jednak w prawdziwych projektach ZAWSZE używaj prepared statements.

Przykład 1: MySQLi obiektowo – PODATNE NA ATAKI! – tak nie rób

<?php
// Połączenie z bazą
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die("Błąd połączenia: {$db->connect_error}");
}

$db->set_charset('utf8mb4');
echo 'Połączenie nawiązano<br><br>';

// Zapytanie SQL - pobierz wszystkich graczy
$sql = "SELECT * FROM players";
$wynik = $db->query($sql);

// Sprawdzamy liczbę rekordów
$ile_wierszy = $wynik->num_rows;
echo "Znaleziono graczy: <strong>{$ile_wierszy}</strong><br><br>";

// Wyświetlamy w tabeli
echo '<table border="1" cellpadding="10" cellspacing="0">';
echo '<tr>
        <th>ID</th>
        <th>Username</th>
        <th>Level</th>
        <th>Coins</th>
        <th>Rank</th>
        <th>Wins/Losses</th>
      </tr>';

// Pętla while - pobieramy kolejne rekordy
while ($wiersz = $wynik->fetch_assoc()) {
    $username = htmlspecialchars($wiersz['username']);
    $coins = number_format($wiersz['coins']);
    
    echo "<tr>";
    echo "<td>{$wiersz['id']}</td>";
    echo "<td>{$username}</td>";
    echo "<td>{$wiersz['level']}</td>";
    echo "<td>{$coins}</td>";
    echo "<td>{$wiersz['rank_name']}</td>";
    echo "<td>{$wiersz['wins']}/{$wiersz['losses']}</td>";
    echo "</tr>";
}

echo '</table>';

// Zamknięcie połączenia
$db->close();
?>

Alternatywnie – pętla foreach

<?php
// ... (połączenie jak wyżej)

$sql = "SELECT * FROM players";
$wynik = $db->query($sql);

echo "Znaleziono graczy: {$wynik->num_rows}<br><br>";

foreach ($wynik as $row) {
    $username = htmlspecialchars($row['username']);
    echo "{$row['id']} - {$username} (Level: {$row['level']})<br>";
}

$db->close();
?>

Przykład 1: PDO – PODATNE NA ATAKI! – tak nie rób

<?php
try {
    // Połączenie PDO
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
        ]
    );
    
    echo 'Połączenie nawiązano<br><br>';
    
    // Zapytanie SQL
    $sql = "SELECT * FROM players";
    $stmt = $pdo->query($sql);
    
    // Pobranie wszystkich wyników
    $wyniki = $stmt->fetchAll();
    
    $liczba_graczy = count($wyniki);
    echo "Znaleziono graczy: <strong>{$liczba_graczy}</strong><br><br>";
    
    // Wyświetlanie w tabeli HTML
    echo '<table border="1" cellpadding="10" cellspacing="0">';
    echo '<tr>
            <th>ID</th>
            <th>Username</th>
            <th>Level</th>
            <th>Coins</th>
            <th>Rank</th>
            <th>Games</th>
            <th>Win Rate</th>
          </tr>';
    
    foreach ($wyniki as $wiersz) {
        // Obliczamy Win Rate (procent wygranych)
        $winRate = $wiersz['games_played'] > 0 
            ? round(($wiersz['wins'] / $wiersz['games_played']) * 100, 1) 
            : 0;
        
        $username = htmlspecialchars($wiersz['username']);
        $coins = number_format($wiersz['coins']);
        
        echo "<tr>";
        echo "<td>{$wiersz['id']}</td>";
        echo "<td>{$username}</td>";
        echo "<td>{$wiersz['level']}</td>";
        echo "<td>{$coins}</td>";
        echo "<td>{$wiersz['rank_name']}</td>";
        echo "<td>{$wiersz['games_played']}</td>";
        echo "<td>{$winRate}%</td>";
        echo "</tr>";
    }
    
    echo '</table>';
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

// Zamknięcie połączenia
$pdo = null;
?>

BEZPIECZEŃSTWO – SQL Injection

SQL Injection to technika ataku, w której hacker wstrzykuje złośliwy kod SQL przez dane wejściowe (formularze, URL). To najczęstszy typ ataku na aplikacje webowe!

NIEBEZPIECZNY KOD (NIGDY TAK NIE RÓB!)

<?php
// PODATNE NA SQL INJECTION!

$username = $_POST['username'];

// Niebezpieczne zapytanie - łączenie stringów!
$sql = "SELECT * FROM players WHERE username = '$username'";
$wynik = $db->query($sql);

/*
PROBLEM: Użytkownik może wpisać złośliwy kod!
*/
?>

Jak działa atak SQL Injection?

Scenariusz 1: Normalny użytkownik
---------------------------------
Użytkownik wpisuje:         ProGamer_Mike
Zapytanie SQL:              SELECT * FROM players WHERE username = 'ProGamer_Mike'
Wynik:                      Zwraca dane gracza


Scenariusz 2: Hacker - ujawnienie wszystkich danych
---------------------------------------------------
Hacker wpisuje:             ' OR '1'='1
Zapytanie SQL:              SELECT * FROM players WHERE username = '' OR '1'='1'
Wynik:                      Zwraca WSZYSTKICH graczy!
Dlaczego?                   '1'='1' jest zawsze prawdą, więc warunek zawsze się spełnia


Scenariusz 3: Hacker - usunięcie tabeli
----------------------------------------
Hacker wpisuje:             '; DROP TABLE players; --
Zapytanie SQL:              SELECT * FROM players WHERE username = ''; DROP TABLE players; --'
Wynik:                      USUWA CAŁĄ TABELĘ! 
Dlaczego?                   Średnik (;) kończy pierwsze zapytanie, DROP TABLE usuwa tabelę,
                           -- komentuje resztę zapytania


Scenariusz 4: Hacker - dodanie administratora
----------------------------------------------
Hacker wpisuje:             '; INSERT INTO users (username, role) VALUES ('hacker', 'admin'); --
Wynik:                      Dodaje sobie konto z uprawnieniami admina! 

Rzeczywiste konsekwencje SQL Injection:

  • Kradzież danych – haker może pobrać hasła, emaile, dane kart kredytowych
  • Usunięcie danych – cała baza może zostać skasowana
  • Modyfikacja danych – zmiana cen produktów, uprawnień użytkowników
  • Przejęcie kontroli – dodanie konta administratora
  • Kara prawna i finansowa – naruszenie RODO, utrata reputacji firmy

BEZPIECZNY KOD – Prepared Statements

BEZPIECZNY KOD – Prepared Statements

Prepared Statements (zapytania przygotowane) to mechanizm, który rozdziela kod SQL od danych. Baza danych najpierw „przygotowuje” zapytanie, a potem osobno przekazuje dane.

Dlaczego są bezpieczne?

  • Separacja kodu i danych – SQL i dane są wysyłane osobno
  • Automatyczne escapowanie – baza wie, że to dane, nie kod
  • Niemożliwy injection – dane nie mogą zmienić struktury zapytania

Jak działają Prepared Statements?

Krok 1: Przygotowanie zapytania (prepare)
------------------------------------------
PHP do MySQL:  "Przygotuj zapytanie: SELECT * FROM players WHERE username = ?"
MySQL:         "OK, czekam na dane do znaku zapytania"


Krok 2: Wysłanie danych (execute/bind_param)
---------------------------------------------
PHP do MySQL:  "Podstaw wartość: ' OR '1'='1"
MySQL:         "OK, traktuję to jako TEKST, nie jako KOD SQL"


Krok 3: Wykonanie
-----------------
MySQL wykonuje: SELECT * FROM players WHERE username = '\' OR \'1\'=\'1\''
                                                          ↑ Escapowane apostrofy
Wynik:          Szuka gracza o nicku "' OR '1'='1" (nie znajdzie takiego)
                NIE WYKONUJE ataku!

Przykład działania – porównanie

// BEZ prepared statements (niebezpieczne):
$username = "' OR '1'='1";
$sql = "SELECT * FROM players WHERE username = '$username'";
// Zapytanie: SELECT * FROM players WHERE username = '' OR '1'='1'
// ATAK SKUTECZNY!


// Z prepared statements (bezpieczne):
$username = "' OR '1'='1";
$stmt = $pdo->prepare("SELECT * FROM players WHERE username = :username");
$stmt->execute(['username' => $username]);
// Zapytanie: SELECT * FROM players WHERE username = '\' OR \'1\'=\'1\''
// Szuka gracza o nicku "' OR '1'='1" - nie znajdzie
// ATAK ZABLOKOWANY!

SELECT z warunkiem WHERE – dane z formularza ($_POST)

Bezpieczne podejście z prepared statements – tak robimy.

Przykład 1: Pobierz wszystkich graczy (prepared statements) – MySQLi obiektowo:

<?php
// Połączenie z bazą
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die("Błąd połączenia: {$db->connect_error}");
}

$db->set_charset('utf8mb4');
echo 'Połączenie nawiązano<br><br>';

// BEZPIECZNE - Prepared statement
// Dla SELECT * bez parametrów też używamy prepare() (dobra praktyka)
$stmt = $db->prepare("SELECT * FROM players");
$stmt->execute();

// Pobranie wyniku
$wynik = $stmt->get_result();

// Sprawdzamy liczbę rekordów
$ile_wierszy = $wynik->num_rows;
echo "Znaleziono graczy: <strong>{$ile_wierszy}</strong><br><br>";

// Wyświetlamy w tabeli
echo '<table border="1" cellpadding="10" cellspacing="0">';
echo '<tr>
        <th>ID</th>
        <th>Username</th>
        <th>Level</th>
        <th>Coins</th>
        <th>Rank</th>
        <th>Wins/Losses</th>
      </tr>';

// Pętla while - pobieramy kolejne rekordy
while ($wiersz = $wynik->fetch_assoc()) {
    $username = htmlspecialchars($wiersz['username']);
    $coins = number_format($wiersz['coins']);
    
    echo "<tr>";
    echo "<td>{$wiersz['id']}</td>";
    echo "<td>{$username}</td>";
    echo "<td>{$wiersz['level']}</td>";
    echo "<td>{$coins}</td>";
    echo "<td>{$wiersz['rank_name']}</td>";
    echo "<td>{$wiersz['wins']}/{$wiersz['losses']}</td>";
    echo "</tr>";
}

echo '</table>';

// Zamknięcie
$stmt->close();
$db->close();
?>

PDO:

<?php
try {
    // Połączenie PDO
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    echo 'Połączenie nawiązano<br><br>';
    
    // BEZPIECZNE - Prepared statement
    $stmt = $pdo->prepare("SELECT * FROM players");
    $stmt->execute();
    
    // Pobranie wszystkich wyników
    $wyniki = $stmt->fetchAll();
    
    $liczba_graczy = count($wyniki);
    echo "Znaleziono graczy: <strong>{$liczba_graczy}</strong><br><br>";
    
    // Wyświetlanie w tabeli HTML
    echo '<table border="1" cellpadding="10" cellspacing="0">';
    echo '<tr>
            <th>ID</th>
            <th>Username</th>
            <th>Level</th>
            <th>Coins</th>
            <th>Rank</th>
            <th>Games</th>
            <th>Win Rate</th>
          </tr>';
    
    foreach ($wyniki as $wiersz) {
        // Obliczamy Win Rate (procent wygranych)
        $winRate = $wiersz['games_played'] > 0 
            ? round(($wiersz['wins'] / $wiersz['games_played']) * 100, 1) 
            : 0;
        
        $username = htmlspecialchars($wiersz['username']);
        $coins = number_format($wiersz['coins']);
        
        echo "<tr>";
        echo "<td>{$wiersz['id']}</td>";
        echo "<td>{$username}</td>";
        echo "<td>{$wiersz['level']}</td>";
        echo "<td>{$coins}</td>";
        echo "<td>{$wiersz['rank_name']}</td>";
        echo "<td>{$wiersz['games_played']}</td>";
        echo "<td>{$winRate}%</td>";
        echo "</tr>";
    }
    
    echo '</table>';
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

// Zamknięcie połączenia
$pdo = null;
?>
  • Używamy htmlspecialchars() dla bezpieczeństwa (ochrona przed XSS)
  • Używamy number_format() dla czytelności liczb (1234 → 1,234)
  • PDO automatycznie zwraca tablice asocjacyjne (ustawiliśmy w opcjach)

Przykład 2: Wyszukiwanie gracza po nicku (formularz POST)

MySQLi obiektowo

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

// Sprawdzamy czy formularz został wysłany
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Pobieramy dane z formularza
    $username = trim($_POST['username'] ?? '');
    
    if (!empty($username)) {
        // BEZPIECZNE - Prepared statement
        $stmt = $db->prepare("SELECT * FROM players WHERE username = ?");
        
        // Bindowanie parametru (s = string)
        $stmt->bind_param("s", $username);
        
        // Wykonanie zapytania
        $stmt->execute();
        
        // Pobranie wyniku
        $wynik = $stmt->get_result();
        
        if ($gracz = $wynik->fetch_assoc()) {
            $safe_username = htmlspecialchars($gracz['username']);
            $safe_email = htmlspecialchars($gracz['email']);
            $coins = number_format($gracz['coins']);
            
            echo "<h2>Profil Gracza</h2>";
            echo "<strong>{$safe_username}</strong><br>";
            echo "Email: {$safe_email}<br>";
            echo "Level: {$gracz['level']}<br>";
            echo "Rank: {$gracz['rank_name']}<br>";
            echo "Coins: {$coins}<br>";
            echo "Games: {$gracz['games_played']} (W: {$gracz['wins']}, L: {$gracz['losses']})<br>";
        } else {
            $safe_search = htmlspecialchars($username);
            echo "Nie znaleziono gracza: {$safe_search}";
        }
        
        $stmt->close();
    } else {
        echo "Wpisz nick gracza!";
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Wyszukaj gracza</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
        }
        form {
            background: #f4f4f4;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        input[type="text"] {
            padding: 10px;
            width: 70%;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #0056b3;
        }
    </style>
</head>
<body>

<h1>Wyszukaj gracza</h1>

<form method="POST">
    <input type="text" name="username" placeholder="Wpisz nick gracza" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" required>
    <button type="submit" name="szukaj">Szukaj</button>
</form>

</body>
</html>

PDO

<?php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    // Sprawdzamy czy formularz został wysłany
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        // Pobieramy dane z formularza
        $username = trim($_POST['username'] ?? '');
        
        if (!empty($username)) {
            // BEZPIECZNE - Prepared statement z named parameter
            $stmt = $pdo->prepare("SELECT * FROM players WHERE username = :username");
            $stmt->execute(['username' => $username]);
            
            $gracz = $stmt->fetch();
            
            if ($gracz) {
                $safe_username = htmlspecialchars($gracz['username']);
                $safe_email = htmlspecialchars($gracz['email']);
                $coins = number_format($gracz['coins']);
                
                echo "<h2>Profil Gracza</h2>";
                echo "<strong>{$safe_username}</strong><br>";
                echo "Email: {$safe_email}<br>";
                echo "Level: {$gracz['level']}<br>";
                echo "Rank: {$gracz['rank_name']}<br>";
                echo "Coins: {$coins}<br>";
                echo "Games: {$gracz['games_played']} (W: {$gracz['wins']}, L: {$gracz['losses']})<br>";
            } else {
                $safe_search = htmlspecialchars($username);
                echo "Nie znaleziono gracza: {$safe_search}";
            }
        } else {
            echo "Wpisz nick gracza!";
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Wyszukaj gracza</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 600px;
            margin: 50px auto;
            padding: 20px;
        }
        form {
            background: #f4f4f4;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        input[type="text"] {
            padding: 10px;
            width: 70%;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #0056b3;
        }
    </style>
</head>
<body>

<h1>Wyszukaj gracza</h1>

<form method="POST">
    <input type="text" name="username" placeholder="Wpisz nick gracza" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" required>
    <button type="submit" name="szukaj">Szukaj</button>
</form>

</body>
</html>

SELECT z warunkiem WHERE – dane z URL ($_GET)

Przykład 3: Profil gracza przez URL (GET)

Adres URL: profil.php?id=5 lub profil.php?username=ProGamer_Mike

MySQLi obiektowo:

<?php
// profil.php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['id'])) {
    // Pobieramy ID z URL
    $player_id = (int)$_GET['id'];
    
    if ($player_id > 0) {
        // BEZPIECZNE - Prepared statement
        $stmt = $db->prepare("SELECT * FROM players WHERE id = ?");
        $stmt->bind_param("i", $player_id);  // i = integer
        $stmt->execute();
        
        $wynik = $stmt->get_result();
        
        if ($gracz = $wynik->fetch_assoc()) {
            $username = htmlspecialchars($gracz['username']);
            $email = htmlspecialchars($gracz['email']);
            $coins = number_format($gracz['coins']);
            
            echo "<h1>Profil gracza</h1>";
            echo "<div style='background:#f4f4f4; padding:20px; border-radius:8px;'>";
            echo "<h2>{$username}</h2>";
            echo "<p>Email: {$email}</p>";
            echo "<p>Level: <strong>{$gracz['level']}</strong></p>";
            echo "<p>Rank: <strong>{$gracz['rank_name']}</strong></p>";
            echo "<p>Coins: <strong>{$coins}</strong></p>";
            echo "<p>Rozegrane gry: {$gracz['games_played']}</p>";
            echo "<p>Wygrane: {$gracz['wins']}</p>";
            echo "<p>Przegrane: {$gracz['losses']}</p>";
            
            // Win Rate
            $winRate = $gracz['games_played'] > 0 
                ? round(($gracz['wins'] / $gracz['games_played']) * 100, 1) 
                : 0;
            echo "<p>Win Rate: <strong>{$winRate}%</strong></p>";
            echo "</div>";
        } else {
            echo "Nie znaleziono gracza o ID: {$player_id}";
        }
        
        $stmt->close();
    } else {
        echo "Nieprawidłowe ID gracza";
    }
} else {
    echo "Podaj ID gracza w URL (np. profil.php?id=5)";
}

$db->close();

// Lista linków do innych graczy
echo "<br><hr><h3>Inni gracze:</h3>";
echo "<a href='profil.php?id=1'>Gracz #1</a> | ";
echo "<a href='profil.php?id=2'>Gracz #2</a> | ";
echo "<a href='profil.php?id=3'>Gracz #3</a> | ";
echo "<a href='profil.php?id=7'>Gracz #7</a>";
?>

PDO:

<?php
// profil.php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    // sprawdzamy metodę HTTP i parametr
    if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['id'])) {
        // Pobieramy ID z URL
        $player_id = (int)$_GET['id'];
        
        if ($player_id > 0) {
            // BEZPIECZNE - Prepared statement
            $stmt = $pdo->prepare("SELECT * FROM players WHERE id = :id");
            $stmt->execute(['id' => $player_id]);
            
            $gracz = $stmt->fetch();
            
            if ($gracz) {
                $username = htmlspecialchars($gracz['username']);
                $email = htmlspecialchars($gracz['email']);
                $coins = number_format($gracz['coins']);
                
                echo "<h1>Profil gracza</h1>";
                echo "<div style='background:#f4f4f4; padding:20px; border-radius:8px;'>";
                echo "<h2>{$username}</h2>";
                echo "<p>Email: {$email}</p>";
                echo "<p>Level: <strong>{$gracz['level']}</strong></p>";
                echo "<p>Rank: <strong>{$gracz['rank_name']}</strong></p>";
                echo "<p>Coins: <strong>{$coins}</strong></p>";
                echo "<p>Rozegrane gry: {$gracz['games_played']}</p>";
                echo "<p>Wygrane: {$gracz['wins']}</p>";
                echo "<p>Przegrane: {$gracz['losses']}</p>";
                
                // Win Rate
                $winRate = $gracz['games_played'] > 0 
                    ? round(($gracz['wins'] / $gracz['games_played']) * 100, 1) 
                    : 0;
                echo "<p>Win Rate: <strong>{$winRate}%</strong></p>";
                echo "</div>";
            } else {
                echo "Nie znaleziono gracza o ID: {$player_id}";
            }
        } else {
            echo "Nieprawidłowe ID gracza";
        }
    } else {
        echo "Podaj ID gracza w URL (np. profil.php?id=5)";
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;

// Lista linków do innych graczy
echo "<br><hr><h3>Inni gracze:</h3>";
echo "<a href='profil.php?id=1'>Gracz #1</a> | ";
echo "<a href='profil.php?id=2'>Gracz #2</a> | ";
echo "<a href='profil.php?id=3'>Gracz #3</a> | ";
echo "<a href='profil.php?id=7'>Gracz #7</a>";
?>

Przykład 4: Wyszukiwarka z wieloma filtrami (formularz POST)

MySQLi obiektowo:

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Pobieramy dane z formularza
    $username = trim($_POST['username'] ?? '');
    $rank = $_POST['rank'] ?? '';
    $min_level = (int)($_POST['min_level'] ?? 0);
    
    // Budujemy zapytanie dynamicznie (BEZPIECZNIE!)
    $sql = "SELECT * FROM players WHERE 1=1";
    $types = "";
    $params = [];
    
    if (!empty($username)) {
        $sql .= " AND username LIKE ?";
        $types .= "s";
        $params[] = "%{$username}%";
    }
    
    if (!empty($rank)) {
        $sql .= " AND rank_name = ?";
        $types .= "s";
        $params[] = $rank;
    }
    
    if ($min_level > 0) {
        $sql .= " AND level >= ?";
        $types .= "i";
        $params[] = $min_level;
    }
    
    $sql .= " ORDER BY level DESC";
    
    // Przygotowanie i wykonanie zapytania
    $stmt = $db->prepare($sql);
    
    if (!empty($params)) {
        $stmt->bind_param($types, ...$params);
    }
    
    $stmt->execute();
    $wynik = $stmt->get_result();
    
    // Wyświetlamy wyniki
    if ($wynik->num_rows > 0) {
        echo "<h3>Znaleziono: {$wynik->num_rows} graczy</h3>";
        
        echo '<table border="1" cellpadding="10" style="width:100%; border-collapse:collapse;">';
        echo '<tr style="background:#007bff; color:white;">
                <th>Username</th>
                <th>Level</th>
                <th>Rank</th>
                <th>Coins</th>
                <th>Games</th>
              </tr>';
        
        while ($gracz = $wynik->fetch_assoc()) {
            $safe_username = htmlspecialchars($gracz['username']);
            $coins = number_format($gracz['coins']);
            
            echo "<tr>";
            echo "<td>{$safe_username}</td>";
            echo "<td>{$gracz['level']}</td>";
            echo "<td>{$gracz['rank_name']}</td>";
            echo "<td>{$coins}</td>";
            echo "<td>{$gracz['games_played']}</td>";
            echo "</tr>";
        }
        
        echo '</table>';
    } else {
        echo "<p>Nie znaleziono graczy spełniających kryteria</p>";
    }
    
    $stmt->close();
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Wyszukiwarka graczy</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 1000px;
            margin: 50px auto;
            padding: 20px;
        }
        form {
            background: #f4f4f4;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        input, select {
            padding: 10px;
            margin: 5px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #0056b3;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        tr:hover {
            background: #f5f5f5;
        }
    </style>
</head>
<body>

<h1>Wyszukiwarka Graczy</h1>

<form method="POST">
    <input type="text" name="username" placeholder="Nick gracza (fragment)" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>">
    
    <select name="rank">
        <option value="">Wszystkie rangi</option>
        <option value="Bronze" <?= ($_POST['rank'] ?? '') == 'Bronze' ? 'selected' : '' ?>>Bronze</option>
        <option value="Silver" <?= ($_POST['rank'] ?? '') == 'Silver' ? 'selected' : '' ?>>Silver</option>
        <option value="Gold" <?= ($_POST['rank'] ?? '') == 'Gold' ? 'selected' : '' ?>>Gold</option>
        <option value="Platinum" <?= ($_POST['rank'] ?? '') == 'Platinum' ? 'selected' : '' ?>>Platinum</option>
        <option value="Diamond" <?= ($_POST['rank'] ?? '') == 'Diamond' ? 'selected' : '' ?>>Diamond</option>
        <option value="Master" <?= ($_POST['rank'] ?? '') == 'Master' ? 'selected' : '' ?>>Master</option>
        <option value="Grandmaster" <?= ($_POST['rank'] ?? '') == 'Grandmaster' ? 'selected' : '' ?>>Grandmaster</option>
    </select>
    
    <input type="number" name="min_level" placeholder="Min. poziom" min="1" value="<?= htmlspecialchars($_POST['min_level'] ?? '') ?>">
    
    <button type="submit" name="szukaj">Szukaj</button>
</form>

</body>
</html>

PDO:

<?php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        // Pobieramy dane z formularza
        $username = trim($_POST['username'] ?? '');
        $rank = $_POST['rank'] ?? '';
        $min_level = (int)($_POST['min_level'] ?? 0);
        
        // Budujemy zapytanie dynamicznie (BEZPIECZNIE!)
        $sql = "SELECT * FROM players WHERE 1=1";
        $params = [];
        
        if (!empty($username)) {
            $sql .= " AND username LIKE :username";
            $params['username'] = "%{$username}%";
        }
        
        if (!empty($rank)) {
            $sql .= " AND rank_name = :rank";
            $params['rank'] = $rank;
        }
        
        if ($min_level > 0) {
            $sql .= " AND level >= :min_level";
            $params['min_level'] = $min_level;
        }
        
        $sql .= " ORDER BY level DESC";
        
        // Wykonujemy zapytanie
        $stmt = $pdo->prepare($sql);
        $stmt->execute($params);
        $wyniki = $stmt->fetchAll();
        
        // Wyświetlamy wyniki
        $liczba = count($wyniki);
        if ($liczba > 0) {
            echo "<h3>Znaleziono: {$liczba} graczy</h3>";
            
            echo '<table border="1" cellpadding="10" style="width:100%; border-collapse:collapse;">';
            echo '<tr style="background:#007bff; color:white;">
                    <th>Username</th>
                    <th>Level</th>
                    <th>Rank</th>
                    <th>Coins</th>
                    <th>Games</th>
                  </tr>';
            
            foreach ($wyniki as $gracz) {
                $safe_username = htmlspecialchars($gracz['username']);
                $coins = number_format($gracz['coins']);
                
                echo "<tr>";
                echo "<td>{$safe_username}</td>";
                echo "<td>{$gracz['level']}</td>";
                echo "<td>{$gracz['rank_name']}</td>";
                echo "<td>{$coins}</td>";
                echo "<td>{$gracz['games_played']}</td>";
                echo "</tr>";
            }
            
            echo '</table>';
        } else {
            echo "<p>Nie znaleziono graczy spełniających kryteria</p>";
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Wyszukiwarka graczy</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 1000px;
            margin: 50px auto;
            padding: 20px;
        }
        form {
            background: #f4f4f4;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
        }
        input, select {
            padding: 10px;
            margin: 5px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        button {
            padding: 10px 20px;
            background: #007bff;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }
        button:hover {
            background: #0056b3;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        tr:hover {
            background: #f5f5f5;
        }
    </style>
</head>
<body>

<h1>Wyszukiwarka Graczy</h1>

<form method="POST">
    <input type="text" name="username" placeholder="Nick gracza (fragment)" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>">
    
    <select name="rank">
        <option value="">Wszystkie rangi</option>
        <option value="Bronze" <?= ($_POST['rank'] ?? '') == 'Bronze' ? 'selected' : '' ?>>Bronze</option>
        <option value="Silver" <?= ($_POST['rank'] ?? '') == 'Silver' ? 'selected' : '' ?>>Silver</option>
        <option value="Gold" <?= ($_POST['rank'] ?? '') == 'Gold' ? 'selected' : '' ?>>Gold</option>
        <option value="Platinum" <?= ($_POST['rank'] ?? '') == 'Platinum' ? 'selected' : '' ?>>Platinum</option>
        <option value="Diamond" <?= ($_POST['rank'] ?? '') == 'Diamond' ? 'selected' : '' ?>>Diamond</option>
        <option value="Master" <?= ($_POST['rank'] ?? '') == 'Master' ? 'selected' : '' ?>>Master</option>
        <option value="Grandmaster" <?= ($_POST['rank'] ?? '') == 'Grandmaster' ? 'selected' : '' ?>>Grandmaster</option>
    </select>
    
    <input type="number" name="min_level" placeholder="Min. poziom" min="1" value="<?= htmlspecialchars($_POST['min_level'] ?? '') ?>">
    
    <button type="submit" name="szukaj">Szukaj</button>
</form>

</body>
</html>

Przykład 5: Ranking graczy – sortowanie i limit (GET)

Adres URL: ranking.php?sort=level&order=desc&limit=10

MySQLi obiektowo:

<?php
// ranking.php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    // Pobieramy parametry z URL
    $sort = $_GET['sort'] ?? 'level';
    $order = $_GET['order'] ?? 'desc';
    $limit = (int)($_GET['limit'] ?? 10);
    
    // Walidacja parametrów (whitelist - tylko dozwolone wartości)
    $allowed_sort = ['level', 'coins', 'wins', 'games_played'];
    $allowed_order = ['asc', 'desc'];
    
    if (!in_array($sort, $allowed_sort)) {
        $sort = 'level';
    }
    
    if (!in_array($order, $allowed_order)) {
        $order = 'desc';
    }
    
    if ($limit < 1 || $limit > 100) {
        $limit = 10;
    }
    
    // BEZPIECZNE - używamy walidacji + prepared statement dla limitu
    // Kolumny sortowania są z whitelisty, więc bezpieczne
    $sql = "SELECT * FROM players ORDER BY {$sort} {$order} LIMIT ?";
    $stmt = $db->prepare($sql);
    $stmt->bind_param("i", $limit);
    $stmt->execute();
    
    $wynik = $stmt->get_result();
    
    $sort_label = [
        'level' => 'poziomu',
        'coins' => 'coins',
        'wins' => 'wygranych',
        'games_played' => 'rozegranych gier'
    ];
    
    echo "<h1>Ranking graczy (wg {$sort_label[$sort]})</h1>";
    echo "<p>Top {$limit} graczy</p>";
    
    echo '<table border="1" cellpadding="10" style="width:100%; border-collapse:collapse;">';
    echo '<tr style="background:#007bff; color:white;">
            <th>#</th>
            <th>Username</th>
            <th>Level</th>
            <th>Rank</th>
            <th>Coins</th>
            <th>Wins</th>
            <th>Games</th>
          </tr>';
    
    $pozycja = 1;
    while ($gracz = $wynik->fetch_assoc()) {
        $username = htmlspecialchars($gracz['username']);
        $coins = number_format($gracz['coins']);
        
        echo "<tr>";
        echo "<td><strong>#{$pozycja}</strong></td>";
        echo "<td>{$username}</td>";
        echo "<td>{$gracz['level']}</td>";
        echo "<td>{$gracz['rank_name']}</td>";
        echo "<td>{$coins}</td>";
        echo "<td>{$gracz['wins']}</td>";
        echo "<td>{$gracz['games_played']}</td>";
        echo "</tr>";
        $pozycja++;
    }
    
    echo '</table>';
    
    echo "<br><p>Zmień sortowanie:</p>";
    echo "<a href='ranking.php?sort=level&order=desc&limit=10'>Top 10 Level</a> | ";
    echo "<a href='ranking.php?sort=coins&order=desc&limit=5'>Top 5 Coins</a> | ";
    echo "<a href='ranking.php?sort=wins&order=desc&limit=20'>Top 20 Wins</a>";
    
    $stmt->close();
}

$db->close();
?>

PDO:

<?php
// ranking.php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    if ($_SERVER['REQUEST_METHOD'] === 'GET') {
        // Pobieramy parametry z URL
        $sort = $_GET['sort'] ?? 'level';
        $order = $_GET['order'] ?? 'desc';
        $limit = (int)($_GET['limit'] ?? 10);
        
        // Walidacja parametrów (whitelist - tylko dozwolone wartości)
        $allowed_sort = ['level', 'coins', 'wins', 'games_played'];
        $allowed_order = ['asc', 'desc'];
        
        if (!in_array($sort, $allowed_sort)) {
            $sort = 'level';
        }
        
        if (!in_array($order, $allowed_order)) {
            $order = 'desc';
        }
        
        if ($limit < 1 || $limit > 100) {
            $limit = 10;
        }
        
        // BEZPIECZNE - używamy walidacji + prepared statement dla limitu
        // Kolumny sortowania są z whitelisty, więc bezpieczne
        $sql = "SELECT * FROM players ORDER BY {$sort} {$order} LIMIT :limit";
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->execute();
        
        $wyniki = $stmt->fetchAll();
        
        $sort_label = [
            'level' => 'poziomu',
            'coins' => 'coins',
            'wins' => 'wygranych',
            'games_played' => 'rozegranych gier'
        ];
        
        echo "<h1>Ranking graczy (wg {$sort_label[$sort]})</h1>";
        echo "<p>Top {$limit} graczy</p>";
        
        echo '<table border="1" cellpadding="10" style="width:100%; border-collapse:collapse;">';
        echo '<tr style="background:#007bff; color:white;">
                <th>#</th>
                <th>Username</th>
                <th>Level</th>
                <th>Rank</th>
                <th>Coins</th>
                <th>Wins</th>
                <th>Games</th>
              </tr>';
        
        $pozycja = 1;
        foreach ($wyniki as $gracz) {
            $username = htmlspecialchars($gracz['username']);
            $coins = number_format($gracz['coins']);
            
            echo "<tr>";
            echo "<td><strong>#{$pozycja}</strong></td>";
            echo "<td>{$username}</td>";
            echo "<td>{$gracz['level']}</td>";
            echo "<td>{$gracz['rank_name']}</td>";
            echo "<td>{$coins}</td>";
            echo "<td>{$gracz['wins']}</td>";
            echo "<td>{$gracz['games_played']}</td>";
            echo "</tr>";
            $pozycja++;
        }
        
        echo '</table>';
        
        echo "<br><p>Zmień sortowanie:</p>";
        echo "<a href='ranking.php?sort=level&order=desc&limit=10'>Top 10 Level</a> | ";
        echo "<a href='ranking.php?sort=coins&order=desc&limit=5'>Top 5 Coins</a> | ";
        echo "<a href='ranking.php?sort=wins&order=desc&limit=20'>Top 20 Wins</a>";
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

Przykład 6: Wyszukiwanie LIKE – dane z GET

Adres URL: szukaj.php?q=Dragon

MySQLi obiektowo:

<?php
// szukaj.php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

// Pobieramy fraze z URL
$szukaj = trim($_GET['q'] ?? '');

if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['q'])) {
    // BEZPIECZNE - Prepared statement z LIKE
    $stmt = $db->prepare("SELECT * FROM players WHERE username LIKE ? OR email LIKE ?");
    
    // Dodajemy % do wyszukiwania
    $szukaj_wildcard = "%{$szukaj}%";
    $stmt->bind_param("ss", $szukaj_wildcard, $szukaj_wildcard);
    $stmt->execute();
    
    $wynik = $stmt->get_result();
    
    $safe_search = htmlspecialchars($szukaj);
    
    if ($wynik->num_rows > 0) {
        echo "<h2>Wyniki wyszukiwania: \"{$safe_search}\"</h2>";
        echo "<p>Znaleziono: {$wynik->num_rows} graczy</p><br>";
        
        while ($gracz = $wynik->fetch_assoc()) {
            $username = htmlspecialchars($gracz['username']);
            $email = htmlspecialchars($gracz['email']);
            $coins = number_format($gracz['coins']);
            
            echo "<div style='background:#f4f4f4; padding:15px; margin:10px 0; border-radius:8px;'>";
            echo "<strong>{$username}</strong> (Level: {$gracz['level']}, Rank: {$gracz['rank_name']})<br>";
            echo "{$email} | {$coins} coins<br>";
            echo "<a href='profil.php?id={$gracz['id']}'>Zobacz profil</a>";
            echo "</div>";
        }
    } else {
        echo "<p>Nie znaleziono graczy dla frazy: \"{$safe_search}\"</p>";
    }
    
    $stmt->close();
} else {
    echo "<p>Wpisz frazę do wyszukania (np. szukaj.php?q=Dragon)</p>";
}

$db->close();

echo "<br><hr>";
echo "<p>Przykłady wyszukiwań:</p>";
echo "<a href='szukaj.php?q=Dragon'>szukaj.php?q=Dragon</a><br>";
echo "<a href='szukaj.php?q=Gamer'>szukaj.php?q=Gamer</a><br>";
echo "<a href='szukaj.php?q=@example.com'>szukaj.php?q=@example.com</a>";
?>

PDO:

<?php
// szukaj.php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    // Pobieramy fraze z URL
    $szukaj = trim($_GET['q'] ?? '');
    
    if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['q'])) {
        // BEZPIECZNE - Prepared statement z LIKE
        $stmt = $pdo->prepare("SELECT * FROM players WHERE username LIKE :szukaj OR email LIKE :szukaj");
        
        // Dodajemy % do wyszukiwania
        $szukaj_wildcard = "%{$szukaj}%";
        $stmt->execute(['szukaj' => $szukaj_wildcard]);
        
        $wyniki = $stmt->fetchAll();
        
        $safe_search = htmlspecialchars($szukaj);
        $liczba = count($wyniki);
        
        if ($liczba > 0) {
            echo "<h2>Wyniki wyszukiwania: \"{$safe_search}\"</h2>";
            echo "<p>Znaleziono: {$liczba} graczy</p><br>";
            
            foreach ($wyniki as $gracz) {
                $username = htmlspecialchars($gracz['username']);
                $email = htmlspecialchars($gracz['email']);
                $coins = number_format($gracz['coins']);
                
                echo "<div style='background:#f4f4f4; padding:15px; margin:10px 0; border-radius:8px;'>";
                echo "<strong>{$username}</strong> (Level: {$gracz['level']}, Rank: {$gracz['rank_name']})<br>";
                echo "{$email} | {$coins} coins<br>";
                echo "<a href='profil.php?id={$gracz['id']}'>Zobacz profil</a>";
                echo "</div>";
            }
        } else {
            echo "<p>Nie znaleziono graczy dla frazy: \"{$safe_search}\"</p>";
        }
    } else {
        echo "<p>Wpisz frazę do wyszukania (np. szukaj.php?q=Dragon)</p>";
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;

echo "<br><hr>";
echo "<p>Przykłady wyszukiwań:</p>";
echo "<a href='szukaj.php?q=Dragon'>szukaj.php?q=Dragon</a><br>";
echo "<a href='szukaj.php?q=Gamer'>szukaj.php?q=Gamer</a><br>";
echo "<a href='szukaj.php?q=@example.com'>szukaj.php?q=@example.com</a>";
?>

Przykład 7: Statystyki – funkcje agregujące

MySQLi obiektowo:

<?php
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');

if ($db->connect_error) {
    die('Błąd połączenia');
}

$db->set_charset('utf8mb4');

// BEZPIECZNE - Prepared statement (brak parametrów użytkownika)
$stmt = $db->prepare("SELECT 
    COUNT(*) as total_players,
    AVG(level) as avg_level,
    SUM(coins) as total_coins,
    MAX(level) as max_level,
    MIN(level) as min_level,
    SUM(games_played) as total_games,
    SUM(wins) as total_wins,
    SUM(losses) as total_losses
FROM players");

$stmt->execute();
$wynik = $stmt->get_result();
$stats = $wynik->fetch_assoc();

$avg_level = round($stats['avg_level'], 1);
$total_coins = number_format($stats['total_coins']);
$total_games = number_format($stats['total_games']);
$total_wins = number_format($stats['total_wins']);
$total_losses = number_format($stats['total_losses']);

echo "<h2>Statystyki Gaming Hub</h2>";
echo "<div style='background:#f4f4f4; padding:20px; border-radius:8px;'>";
echo "<p>Liczba graczy: <strong>{$stats['total_players']}</strong></p>";
echo "<p>Średni poziom: <strong>{$avg_level}</strong></p>";
echo "<p>Łączne coins: <strong>{$total_coins}</strong></p>";
echo "<p>Najwyższy poziom: <strong>{$stats['max_level']}</strong></p>";
echo "<p>Najniższy poziom: <strong>{$stats['min_level']}</strong></p>";
echo "<p>Rozegrane gry: <strong>{$total_games}</strong></p>";
echo "<p>Wygrane: <strong>{$total_wins}</strong></p>";
echo "<p>Przegrane: <strong>{$total_losses}</strong></p>";
echo "</div>";

$stmt->close();
$db->close();
?>

PDO:

<?php
try {
    $pdo = new PDO(
        'mysql:host=localhost;dbname=gaming_hub;charset=utf8mb4',
        'root',
        '',
        [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]
    );
    
    // BEZPIECZNE - Prepared statement (brak parametrów użytkownika)
    $stmt = $pdo->prepare("SELECT 
        COUNT(*) as total_players,
        AVG(level) as avg_level,
        SUM(coins) as total_coins,
        MAX(level) as max_level,
        MIN(level) as min_level,
        SUM(games_played) as total_games,
        SUM(wins) as total_wins,
        SUM(losses) as total_losses
    FROM players");
    
    $stmt->execute();
    $stats = $stmt->fetch();
    
    $avg_level = round($stats['avg_level'], 1);
    $total_coins = number_format($stats['total_coins']);
    $total_games = number_format($stats['total_games']);
    $total_wins = number_format($stats['total_wins']);
    $total_losses = number_format($stats['total_losses']);
    
    echo "<h2>Statystyki Gaming Hub</h2>";
    echo "<div style='background:#f4f4f4; padding:20px; border-radius:8px;'>";
    echo "<p>Liczba graczy: <strong>{$stats['total_players']}</strong></p>";
    echo "<p>Średni poziom: <strong>{$avg_level}</strong></p>";
    echo "<p>ączne coins: <strong>{$total_coins}</strong></p>";
    echo "<p>Najwyższy poziom: <strong>{$stats['max_level']}</strong></p>";
    echo "<p>Najniższy poziom: <strong>{$stats['min_level']}</strong></p>";
    echo "<p>Rozegrane gry: <strong>{$total_games}</strong></p>";
    echo "<p>Wygrane: <strong>{$total_wins}</strong></p>";
    echo "<p>Przegrane: <strong>{$total_losses}</strong></p>";
    echo "</div>";
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

Whitelist vs Blacklist

Whitelist (ZALECANE):

// Lista dozwolonych wartości
$allowed_sort = ['level', 'coins', 'wins'];

if (in_array($_GET['sort'], $allowed_sort)) {
    $sort = $_GET['sort'];  // Bezpieczne
} else {
    $sort = 'level';  // Domyślna wartość
}

Blacklist (NIE ZALECANE):

// Próba blokowania "złych" wartości - NIGDY nie złapiesz wszystkich!
if ($_GET['sort'] != 'DROP' && $_GET['sort'] != 'DELETE') {
    $sort = $_GET['sort'];  // NADAL NIEBEZPIECZNE!
}

Najlepsze praktyki

ZAWSZE:

  1. Używaj prepared statements dla WSZYSTKICH zapytań z danymi użytkownika
  2. Używaj PDO dla nowych projektów (uniwersalność + bezpieczeństwo)
  3. Używaj htmlspecialchars() przy wyświetlaniu danych (ochrona XSS)
  4. Waliduj dane wejściowe (whitelist dla sortowania, LIMIT, itp.)
  5. Ustawiaj charset na utf8mb4 (obsługa emoji i polskich znaków)
  6. Loguj błędy do pliku, nie pokazuj szczegółów użytkownikowi
  7. Używaj number_format() dla czytelności dużych liczb
  8. Rzutuj na właściwy typ – (int) dla liczb, trim() dla stringów

NIGDY:

  1. Nie łącz zapytań SQL przez konkatenację z danymi użytkownika
  2. Nie pokazuj szczegółów błędów SQL użytkownikowi końcowemu
  3. Nie trzymaj danych dostępowych w kodzie (użyj config.php)
  4. Nie commituj config.php do repozytorium
  5. Nie używaj root bez hasła w środowisku produkcyjnym
  6. Nie ufaj danym z $_GET i $_POST – zawsze waliduj!

TaskQuest 1.8 — każdy gracz widzi swoje dane z bazy

KARTA PRACY

Po Lekcji 17 TaskQuest ma bazę danych taskquest z dwiema tabelami i danymi testowymi Kamy i Oli. Aplikacja jednak w ogóle z niej nie korzysta: zadania czyta z data/zadania.csv, a XP i streak są wpisane w index.php. Kuba i Ola po zalogowaniu nadal widzą te same zadania i te same 99 XP.

Dziś połączymy PHP z bazą i przełączymy stronę główną na dane z bazy. Po tej lekcji każdy użytkownik zobaczy własne zadania, własne XP i własny streak.

Cel lekcji

Nauczyć się:

  • łączyć się z bazą w PHP obiektowym mysqli (new mysqli(...)) i obsługiwać błąd połączenia wyjątkiem,
  • pisać zapytania SELECT z WHERE, AND, IS NULL, ORDER BY i LIMIT,
  • odczytywać wynik zapytania wiersz po wierszu (fetch_assoc()) i zamieniać wiersze w obiekty Zadanie i Uzytkownik,
  • rozpoznać SQL injection i przed nim się chronić: dane od użytkownika zawsze przekazujemy przez prepared statements (prepare(), bind_param(), execute(), get_result()),
  • zapisywać typ „wartość albo null” w PHP (?string, ?int).

Efekt: logowanie sprawdza login w bazie, index.php pobiera z bazy zalogowanego użytkownika i tylko jego zadania, a pod listą zadań pojawia się ranking najlepszych graczy.

Uwaga — co jest, a czego jeszcze nie ma:

  • Dziś tylko czytamy z bazy (SELECT). Zapis (INSERT, UPDATE, DELETE) to Lekcja 19. Dlatego dodawanie zadań jest w tej wersji wyłączone z nawigacji: formularz dodaj-zadanie.php nadal zapisuje do CSV, a strona główna czyta już z bazy. W Lekcji 19 formularz przejdzie na INSERT i wróci na stronę.
  • Logowanie nadal jest demonstracyjne: bez hasła. Różnica: login musi istnieć w bazie. Hasła — Lekcja 19 i 22.
  • Streak nadal jest liczony uproszczoną metodą z poprzednich lekcji. Prawdziwy streak (data wykonania zadania) — Lekcja 19.
  • mysqli używamy wyłącznie obiektowo. W internecie (i w starszych materiałach) zobaczysz mysqli_connect(), @new mysqli(...) i sprawdzanie connect_error. My obsługujemy błędy wyjątkami, tak jak od Lekcji 12.

Rozgrzewka — 4 pytania z Lekcji 17

  1. Dlaczego w tabeli zadania jest kolumna data_wykonania, a nie ma kolumny wykonane? Jak w PHP obliczymy wykonane?
  2. Kama ma id = 1, Ola ma id = 2. Która kolumna tabeli zadania mówi, czyje jest zadanie?
  3. Czym dla bazy różnią się NULL, '' i 'NULL'?
  4. diagnostyka.php pokazuje [OK] przy mysqli. Czego to jeszcze nie gwarantuje?

1. Wracamy do naszego TaskQuest

Co zostaje, co dochodzi, co zmieniamy

ElementPo Lekcji 17Po Lekcji 18
baza taskquest, sql/taskquest.sqlistnieje, nieużywanazostaje bez zmian — teraz aplikacja z niej czyta
klasa Zadanieistniejezostaje bez zmian
klasa Uzytkownikbez id, dataOstatniejAktywnosci typu stringzmiana: dochodzi id + pobierzId(), data jako ?string
symulowane xp: 99, streak: 5, ... w index.phpźródło danych graczausuwamy — dane gracza z tabeli uzytkownicy
wczytajZadania(PLIK_ZADAN) w index.phpźródło zadańzmiana: index.php czyta zadania z bazy; funkcja zostaje (używa jej jeszcze dodaj-zadanie.php)
includes/funkcje_bazy.phpnie madochodzi: połączenie + 4 funkcje z SELECT
config/config.php3 stałedochodzą 4 stałe z danymi dostępowymi do bazy
logowanielogin + imię, każdy login „wchodzi”zmiana: tylko login, musi istnieć w bazie; w sesji tylko uzytkownik_id
wszystkie funkcje listy (sortujPoTerminie() itd.), grywalizacji, terminówistniejązostają bez zmian

Najważniejsza zasada tej lekcji jest ta sama co w Lekcji 17 (sekcja 6): zmienia się źródło danych, obiekty zostają te same. Dlatego większość index.php — liczniki, filtr widoku, pasek XP, lista — nie zmieni się ani o znak.

Docelowa struktura projektu

taskquest/
├── index.php                        (zmiana: dane z bazy, bez linku „+ Dodaj zadanie”, ranking)
├── dodaj-zadanie.php                bez zmian (nadal CSV; wyłączony z nawigacji do L19)
├── zaloguj.php                      (zmiana: login sprawdzany w bazie, bez pola „imię”)
├── wyloguj.php                      bez zmian
├── diagnostyka.php                  (zmiana: 19 wymaganych plików)
├── classes/
│   ├── Zadanie.php                  bez zmian
│   └── Uzytkownik.php               (zmiana: id, ?string)
├── config/
│   └── config.php                   (zmiana: stałe BAZA_...)
├── includes/
│   ├── footer.php                   (zmiana: „TaskQuest 1.8”)
│   ├── funkcje_bazy.php             NOWY
│   ├── funkcje_sesji.php            (zmiana: czyZalogowany())
│   ├── funkcje_tekstowe.php         (zmiana: walidujDaneLogowania())
│   └── ... pozostałe                bez zmian
├── sql/
│   └── taskquest.sql                bez zmian
└── data/
    └── zadania.csv                  bez zmian (czyta go już tylko dodaj-zadanie.php)

Przygotowanie (zrób to teraz)

  1. Zrób kopię zapasową katalogu taskquest/ jako taskquest_lekcja17/ (obok, nie w środku).
  2. Uruchom w XAMPP Apache i MySQL.
  3. W phpMyAdmin uruchom ponownie cały sql/taskquest.sql (baza taskquest → zakładka SQL). Zaczynamy od czystych danych testowych.

2. Problem w aplikacji

Ola loguje się do TaskQuest jako Ola_TQ. Co widzi?

Co widzi OlaCo jest w bazie dla Oli
„Witaj, Ola!”imię Ola ✔
99 XP, poziom 1250 XP, poziom 3
streak 5streak 0
zadania Kamy z CSVjedno własne zadanie „Zaprojektować bazę TaskQuest”

Imię się zgadza tylko dlatego, że Ola sama je wpisała w formularzu. Co więcej, ktoś może się zalogować jako Nieistniejacy_123 — takiego konta nie ma nigdzie, a TaskQuest i tak go wpuści.

Potrzebujemy trzech rzeczy:

  1. połączenia PHP z bazą,
  2. zapytania, które zwróci dane jednego konkretnego użytkownika i tylko jego zadania,
  3. bezpiecznego sposobu wstawienia do zapytania danych z zewnątrz (login wpisany w formularzu, id z sesji) — bo, jak zaraz zobaczysz, najprostszy sposób jest groźny.

3. Nowy mechanizm: SELECT i mysqli

3.1 SELECT — zadajemy bazie pytania (phpMyAdmin)

W Lekcji 17 używaliśmy tylko SELECT *. Teraz poznamy pełniejszą postać. Otwórz phpMyAdmin → baza taskquest → zakładka SQL. Przed uruchomieniem każdego zapytania przewidź, ile wierszy zwróci.

SELECT tytul, termin FROM zadania;
SELECT tytul, termin FROM zadania WHERE uzytkownik_id = 1;
SELECT tytul, termin FROM zadania WHERE uzytkownik_id = 1 AND data_wykonania IS NULL;
SELECT tytul, termin FROM zadania WHERE uzytkownik_id = 1 ORDER BY termin;
SELECT login, xp FROM uzytkownicy ORDER BY xp DESC LIMIT 1;
FragmentZnaczenieOdpowiednik w TaskQuest
SELECT tytul, terminktóre kolumny chcemy dostać—
FROM zadaniaz której tabeliktóry plik CSV
WHERE uzytkownik_id = 1tylko wiersze spełniające warunekarray_filter() w pobierzAktywneZadania()
ANDoba warunki muszą być spełnione&& w PHP
IS NULL / IS NOT NULLczy wartość jest pusta (NULL)=== null / !== null w PHP
ORDER BY terminsortowanie rosnące (domyślnie ASC)sortujPoTerminie()
ORDER BY xp DESCsortowanie malejące—
LIMIT 1najwyżej tyle wierszy—

Kolejność części jest stała: SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT .... Zamiana miejscami daje błąd składni.

Dlaczego wymieniamy kolumny zamiast *? Pobieramy tylko to, czego potrzebujemy. W tabeli uzytkownicy jest kolumna haslo_hash — od Lekcji 19 będzie w niej skrót hasła. SELECT * przy wyświetlaniu rankingu przeciągnąłby go do PHP zupełnie niepotrzebnie.

Pułapka: sprawdź teraz

SELECT tytul FROM zadania WHERE data_wykonania = NULL;

Wynik: zero wierszy, bez błędu, choć niewykonane zadania istnieją. NULL oznacza „nie wiadomo”, a „czy nie wiadomo jest równe nie wiadomo?” daje w SQL odpowiedź „nie wiadomo”, czyli nie „prawda”. Do NULL używamy tylko IS NULL / IS NOT NULL. To jeden z najczęstszych błędów początkujących, bo baza nic nie zgłasza.

3.2 Połączenie z bazą w PHP

W katalogu taskquest/ utwórz brudnopis.php:

<?php

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

$polaczenie = new mysqli('localhost', 'root', '', 'taskquest');
$polaczenie->set_charset('utf8mb4');

echo 'Połączono. Serwer: ' . $polaczenie->server_info;

Uruchom http://localhost/taskquest/brudnopis.php.

LiniaPo co
mysqli_report(...)ustawia, jak mysqli zgłasza błędy: MYSQLI_REPORT_ERROR — zgłaszaj błędy, MYSQLI_REPORT_STRICT — jako wyjątki. Od PHP 8.1 to ustawienie domyślne, ale piszemy je jawnie: kod nie zależy od wersji PHP, a czytający od razu wie, czego się spodziewać
new mysqli(host, użytkownik, hasło, baza)tworzy obiekt połączenia (klasa mysqli, tak jak od Lekcji 15 tworzysz new Zadanie(...)). XAMPP ma domyślnie użytkownika root bez hasła
set_charset('utf8mb4')PHP i baza umawiają się na kodowanie znaków. Bez tego polskie litery i emoji mogą przychodzić zniekształcone — zależnie od ustawień serwera
$polaczenie->server_infowłaściwość obiektu: wersja serwera. Tu tylko dowód, że połączenie działa

Teraz zmień nazwę bazy na taskquestt i uruchom ponownie. Zobaczysz Fatal error: Uncaught mysqli_sql_exception: Unknown database 'taskquestt'. To znany Ci mechanizm z Lekcji 12: wyjątek, którego nikt nie złapał. mysqli_sql_exception to klasa wyjątku z rozszerzenia mysqli; jest rodzajem Exception, więc złapie go zwykłe catch (Exception $wyjatek). Przywróć nazwę taskquest.

Połączenie zamyka się samo, gdy skrypt się kończy. Można je zamknąć wcześniej: $polaczenie->close();. W TaskQuest jedno żądanie = jedno krótkie połączenie, więc zostawiamy to PHP.

3.3 Pierwsze zapytanie z PHP

Dopisz do brudnopisu:

$wynik = $polaczenie->query('SELECT id, login, imie, xp FROM uzytkownicy');

echo '<p>Liczba użytkowników: ' . $wynik->num_rows . '</p>';

while ($wiersz = $wynik->fetch_assoc()) {
    echo "<p>{$wiersz['id']}. {$wiersz['login']} ({$wiersz['imie']}) — {$wiersz['xp']} XP</p>";
}

Po drodze powstają trzy różne rzeczy:

$polaczenie  (obiekt mysqli)        ──query()──►  $wynik  (obiekt mysqli_result)
$wynik                              ──fetch_assoc()──►  $wiersz  (tablica asocjacyjna, klucze = nazwy kolumn)
                                                        albo null, gdy wiersze się skończyły
  • query() wysyła zapytanie i zwraca wynik — coś w rodzaju otwartego pliku CSV, z którego czytamy wiersz po wierszu.
  • fetch_assoc() zwraca kolejny wiersz, a po ostatnim zwraca null. Dlatego while ($wiersz = $wynik->fetch_assoc()) działa jak pętla po fgetcsv() z Lekcji 11: przypisz, sprawdź, powtarzaj.
  • num_rows — liczba wierszy wyniku.

Dopisz na końcu brudnopisu i przewidź, co pokaże:

$wynik = $polaczenie->query('SELECT id, xp, data_ostatniej_aktywnosci FROM uzytkownicy');
var_dump($wynik->fetch_assoc());
var_dump($wynik->fetch_assoc());
var_dump($wynik->fetch_assoc());

Wynik: id i xp przyszły jako teksty (string(1) "1", string(2) "99"), data jako tekst "2026-09-14", NULL u Oli jako PHP-owe null, a trzecie wywołanie zwraca null (użytkowników jest dwóch). Zapamiętaj te teksty — w 3.5 zobaczysz różnicę.

3.4 Dane od użytkownika w zapytaniu — i dlaczego tak NIE robimy

TaskQuest musi znaleźć użytkownika po loginie wpisanym w formularzu. Najprostszy pomysł: wkleić login do tekstu zapytania. Zastąp zawartość brudnopisu:

<?php

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$polaczenie = new mysqli('localhost', 'root', '', 'taskquest');
$polaczenie->set_charset('utf8mb4');

// ŹLE — wyłącznie do tej demonstracji!
$login = $_GET['login'] ?? '';
$sql = "SELECT id, login, xp FROM uzytkownicy WHERE login = '$login'";

echo "<p>Zapytanie: $sql</p>";

$wynik = $polaczenie->query($sql);
echo "<p>Znaleziono: {$wynik->num_rows}</p>";

while ($wiersz = $wynik->fetch_assoc()) {
    echo "<p>{$wiersz['login']} — {$wiersz['xp']} XP</p>";
}

Uruchamiaj po kolei i za każdym razem czytaj linię „Zapytanie: …”:

AdresWynikCo się stało
brudnopis.php?login=Ola_TQ1 wierszdziała
brudnopis.php?login=Nowy_10 wierszydziała
brudnopis.php?login=O'NeilFatal error: Uncaught mysqli_sql_exception: You have an error in your SQL syntax...apostrof z loginu zamknął tekst w SQL za wcześnie
brudnopis.php?login=' OR '1'='12 wiersze — wszyscy użytkownicypatrz niżej

Spójrz na ostatnie zapytanie:

SELECT id, login, xp FROM uzytkownicy WHERE login = '' OR '1'='1'

Tekst wpisany przez użytkownika stał się częścią polecenia SQL. Warunek '1'='1' jest zawsze prawdziwy, więc zapytanie zwraca wszystkich. To SQL injection (wstrzykiwanie SQL) — od lat jedna z najczęściej wykorzystywanych podatności aplikacji webowych. Tą samą metodą da się ominąć logowanie albo wyciągnąć dane z innych tabel.

Ktoś powie: „przecież walidujLogin() nie przepuści apostrofu”. To prawda — dla loginu. Ale wyszukiwarka zadań (zadanie końcowe A) musi przyjąć dowolny tekst, a opis zadania może zawierać apostrof. Walidacja to pierwsza linia obrony, a zapytanie ma być bezpieczne samo z siebie.

Testuj takie rzeczy wyłącznie na własnej, lokalnej bazie. Próba włamania do cudzego systemu jest przestępstwem.

3.5 Prepared statements — dane osobno, polecenie osobno

Problem wziął się z tego, że polecenie i dane zostały sklejone w jeden tekst. Prepared statement (zapytanie przygotowane) rozdziela je:

1. prepare()     wysyłamy SZABLON polecenia:  ... WHERE login = ?
                 baza analizuje go i zapamiętuje — to jest już gotowe polecenie
2. bind_param()  podpinamy dane pod znak ?
3. execute()     wysyłamy dane osobno; baza wstawia je jako WARTOŚĆ, nigdy jako kod
4. get_result()  odbieramy wynik — dalej jak w 3.3 (fetch_assoc())

Cokolwiek wpisze użytkownik, baza porówna to z loginem jako zwykły tekst. ' OR '1'='1 to po prostu dziwny login, którego nikt nie ma.

Zamień w brudnopisie część od // ŹLE do końca na:

$login = $_GET['login'] ?? '';

$zapytanie = $polaczenie->prepare('SELECT id, login, xp FROM uzytkownicy WHERE login = ?');
$zapytanie->bind_param('s', $login);
$zapytanie->execute();
$wynik = $zapytanie->get_result();

echo "<p>Znaleziono: {$wynik->num_rows}</p>";

while ($wiersz = $wynik->fetch_assoc()) {
    var_dump($wiersz);
}

bind_param() ma dwa rodzaje argumentów:

  • tekst z typami — po jednej literze na każdy ?, w tej samej kolejności: s — tekst (string), i — liczba całkowita (integer), d — liczba z przecinkiem (double),
  • zmienne z wartościami, w tej samej kolejności co znaki ?.

Przykład z dwoma parametrami: WHERE uzytkownik_id = ? AND tytul = ? → bind_param('is', $uzytkownikId, $tytul).

Powtórz cztery adresy z tabeli w 3.4. Oczekiwane: Ola_TQ → 1, Nowy_1 → 0, O'Neil → 0, bez błędu, ' OR '1'='1 → 0.

Teraz spójrz na var_dump przy Ola_TQ: ["id"]=> int(2), ["xp"]=> int(250). W 3.3 były teksty, tu są liczby. Prepared statements przesyłają wynik w innym formacie (tzw. protokół binarny) i mysqli zamienia kolumny INT na PHP-owe int. Daty (DATE) nadal przychodzą jako tekst RRRR-MM-DD, a NULL jako null. Dla TaskQuest to wygodne: typy pasują do właściwości Zadanie i Uzytkownik.

Zasada TaskQuest od dziś: każde zapytanie, w którym jest jakakolwiek wartość z zewnątrz (formularz, adres, cookie, sesja), piszemy jako prepared statement. Sklejanie SQL ze zmiennymi ("... '$login'") jest zakazane. query() zostaje tylko dla zapytań bez żadnych zmiennych — a dla porządku i tak w TaskQuest ich nie używamy.

Typowe błędy

BłądSkutek
WHERE login = '?' (znak zapytania w apostrofach)to już nie parametr, tylko tekst „?”; bind_param() zgłasza błąd o niezgodnej liczbie parametrów
liczba liter w typach ≠ liczba ?błąd o niezgodnej liczbie parametrów
bind_param('i', 5) — wartość zamiast zmiennejError: argumentu nie da się przekazać przez referencję. bind_param() wymaga zmiennych
$zapytanie->fetch_assoc() bez get_result()Error: taka metoda nie istnieje. fetch_assoc() ma wynik, nie zapytanie
WHERE data_wykonania = NULLzawsze 0 wierszy, bez błędu (3.1)
SELECT * „bo wygodniej”pobieramy kolumny, których nie potrzebujemy (np. haslo_hash)

4. SAMODZIELNIE — zadania wybranego użytkownika jako obiekty

Nadal w brudnopisie. Napisz skrypt, który:

  1. dołącza classes/Zadanie.php i includes/funkcje_tekstowe.php,
  2. łączy się z bazą jak w 3.2,
  3. bierze id użytkownika z adresu (brudnopis.php?id=1); jeśli id nie składa się z samych cyfr, wyświetla Nieprawidłowe id. i kończy działanie,
  4. prepared statementem pobiera kolumny id, tytul, opis, termin, data_wykonania, xp zadań tego użytkownika,
  5. z każdego wiersza tworzy obiekt Zadanie (argumenty nazwane, jak w Lekcji 17, sekcja 6) i dopisuje go do tablicy $zadania,
  6. na końcu wyświetla liczbę zadań i dla każdego: [X] albo [ ] oraz oczyszczony tytuł.

Oczekiwane: ?id=1 — zadania Kamy (tyle, ile w bazie), ?id=2 — jedno zadanie Oli z [ ], ?id=3 — Zadań: 0, ?id=abc — Nieprawidłowe id.

HINT

Sprawdzanie liczby z adresu robiłaś/robiłeś w Lekcji 13 (wyrażenie regularne przed (int)). Pętla po wynikach wygląda jak w 3.3, tylko zamiast echo jest $zadania[] = new Zadanie(...). Który typ ma parametr: s czy i?

POMOC
$zapytanie = $polaczenie->prepare('SELECT ... FROM zadania WHERE uzytkownik_id = ?');
$zapytanie->bind_param('i', $uzytkownikId);
wykonane: $wiersz['data_wykonania'] !== null,
ROZWIĄZANIE
<?php

require_once __DIR__ . '/classes/Zadanie.php';
require_once __DIR__ . '/includes/funkcje_tekstowe.php';

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$polaczenie = new mysqli('localhost', 'root', '', 'taskquest');
$polaczenie->set_charset('utf8mb4');

$idZAdresu = $_GET['id'] ?? '';

if (!preg_match('/^\d+$/', $idZAdresu)) {
    echo 'Nieprawidłowe id.';
    exit;
}

$uzytkownikId = (int) $idZAdresu;

$zapytanie = $polaczenie->prepare(
    'SELECT id, tytul, opis, termin, data_wykonania, xp FROM zadania WHERE uzytkownik_id = ?'
);
$zapytanie->bind_param('i', $uzytkownikId);
$zapytanie->execute();
$wynik = $zapytanie->get_result();

$zadania = [];

while ($wiersz = $wynik->fetch_assoc()) {
    $zadania[] = new Zadanie(
        id: $wiersz['id'],
        tytul: $wiersz['tytul'],
        opis: $wiersz['opis'],
        termin: $wiersz['termin'],
        wykonane: $wiersz['data_wykonania'] !== null,
        xp: $wiersz['xp'],
    );
}

echo '<p>Zadań: ' . count($zadania) . '</p>';

foreach ($zadania as $zadanie) {
    $znacznik = $zadanie->czyWykonane() ? '[X]' : '[ ]';
    echo '<p>' . $znacznik . ' ' . wyczyscTytul($zadanie->pobierzTytul()) . '</p>';
}
  • id i xp nie potrzebują (int) — z prepared statementu przychodzą już jako int (3.5). Porównaj z wczytajZadania(), gdzie (int) $wiersz[0] był konieczny, bo CSV zawiera same teksty.
  • Nie ma tu odpowiednika sprawdzWierszZadania(). Baza nie wpuści złego terminu, ujemnego XP ani zbyt długiego tytułu (Test 4 z Lekcji 17), więc wiersz z bazy ma zawsze poprawny kształt.
  • ?id=3 nie jest błędem: zapytanie jest poprawne, po prostu nie ma pasujących wierszy — pusta tablica, tak jak pusty plik CSV w Lekcji 12 nie był awarią.

5. Włączamy bazę do TaskQuest

Od tego miejsca pracujesz na projekcie. brudnopis.php zostaw — przyda się do testowania nowych funkcji, zanim podłączysz je do stron.

Plan: najpierw budujemy i testujemy w brudnopisie wszystkie funkcje (kroki 1–5), potem w jednym ruchu przełączamy logowanie i stronę główną (kroki 6–7). Między krokiem 6 a 7 aplikacja nie działa — wykonaj je bez przerwy.

Krok 1 — dane dostępowe w config/config.php

Dane dostępowe wpisaliśmy w brudnopisie trzy razy. W projekcie mają być w jednym miejscu — tam, gdzie od Lekcji 10 trzymamy ustawienia. Na końcu config/config.php dopisz:

// (L18) dane dostępowe do bazy danych — domyślne ustawienia XAMPP
define('BAZA_HOST', 'localhost');
define('BAZA_UZYTKOWNIK', 'root');
define('BAZA_HASLO', '');
define('BAZA_NAZWA', 'taskquest');

Na szkolnym albo prawdziwym serwerze te wartości będą inne (i nigdy root bez hasła). Dzięki stałym zmienisz je w jednym pliku.

Krok 2 — plik includes/funkcje_bazy.php i funkcja polaczZBaza()

Utwórz includes/funkcje_bazy.php:

<?php

// Funkcje TaskQuest korzystające z bazy danych (Lekcja 18).
// Wymagają wcześniejszego dołączenia config/config.php (stałe BAZA_...).
// Funkcje tworzące obiekty wymagają też classes/Zadanie.php i classes/Uzytkownik.php.

function polaczZBaza(): mysqli
{
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

    try {
        $polaczenie = new mysqli(BAZA_HOST, BAZA_UZYTKOWNIK, BAZA_HASLO, BAZA_NAZWA);
    } catch (Exception $wyjatek) {
        throw new Exception('Nie udało się połączyć z bazą danych.');
    }

    $polaczenie->set_charset('utf8mb4');

    return $polaczenie;
}
  • Typ zwracany mysqli — funkcja zwraca obiekt klasy, tak jak metoda mogłaby zwracać Zadanie.
  • Dlaczego łapiemy wyjątek i rzucamy nowy? Komunikat z mysqli przy złym haśle brzmi mniej więcej: Access denied for user 'root'@'localhost' (using password: YES). Zdradza nazwę użytkownika bazy i adres serwera. Od Lekcji 12 komunikaty wyjątków trafiają do użytkownika, więc mają być dla niego — bez szczegółów technicznych. Eksperyment 4 pokaże różnicę.
  • Plik z samym PHP — bez zamykającego ?> (konwencja od Lekcji 10).

Krok 3 — SAMODZIELNIE: wczytajZadaniaUzytkownika()

Kod z sekcji 4 zamień w funkcję w funkcje_bazy.php (pod polaczZBaza()):

function wczytajZadaniaUzytkownika(mysqli $polaczenie, int $uzytkownikId): array

Wymagania:

  • funkcja nie tworzy połączenia — dostaje je w parametrze (jedno połączenie wystarczy na całe żądanie, a index.php wykona kilka zapytań),
  • zwraca tablicę obiektów Zadanie — dokładnie to samo, co zwraca wczytajZadania() z CSV; dla użytkownika bez zadań [],
  • sortuje po id (ORDER BY id), żeby kolejność była stała, jak kolejność linii w CSV. Sortowaniem po terminie nadal zajmuje się sortujPoTerminie() — nie zmieniamy działającej logiki,
  • niczego nie wyświetla i nie sprawdza id regexem (int $uzytkownikId gwarantuje liczbę; sprawdzanie tekstu z adresu to zadanie strony, nie tej funkcji).

Przetestuj w brudnopisie:

<?php

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/classes/Zadanie.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

$polaczenie = polaczZBaza();
var_dump(count(wczytajZadaniaUzytkownika($polaczenie, 1)));
var_dump(count(wczytajZadaniaUzytkownika($polaczenie, 2)));
var_dump(wczytajZadaniaUzytkownika($polaczenie, 3));

Oczekiwane: liczba zadań Kamy, int(1), array(0) {}.

HINT

Cała środkowa część rozwiązania z sekcji 4 (od prepare do końca pętli) przechodzi do funkcji prawie bez zmian. Czego w funkcji nie ma? Połączenia, $_GET, echo.

ROZWIĄZANIE
function wczytajZadaniaUzytkownika(mysqli $polaczenie, int $uzytkownikId): array
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, tytul, opis, termin, data_wykonania, xp FROM zadania WHERE uzytkownik_id = ? ORDER BY id'
    );
    $zapytanie->bind_param('i', $uzytkownikId);
    $zapytanie->execute();
    $wynik = $zapytanie->get_result();

    $zadania = [];

    while ($wiersz = $wynik->fetch_assoc()) {
        $zadania[] = new Zadanie(
            id: $wiersz['id'],
            tytul: $wiersz['tytul'],
            opis: $wiersz['opis'],
            termin: $wiersz['termin'],
            wykonane: $wiersz['data_wykonania'] !== null,
            xp: $wiersz['xp'],
        );
    }

    return $zadania;
}

Błędów zapytania (np. literówki w nazwie kolumny) ta funkcja nie łapie. To błędy programisty, a nie awaria, której użytkownik może się spodziewać. Wyjątek poleci wyżej, do strony (Eksperyment 3).

Krok 4 — Uzytkownik dostaje id, a data może być null

Zanim napiszemy funkcję wczytującą użytkownika, sprawdźmy, czy wiersz Oli w ogóle zmieści się w obiekcie. Brudnopis:

<?php

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/classes/Uzytkownik.php';
require_once __DIR__ . '/includes/funkcje_grywalizacji.php';
require_once __DIR__ . '/includes/funkcje_terminow.php';

$uzytkownik = new Uzytkownik(
    login: 'Ola_TQ',
    imieUzytkownika: 'Ola',
    xp: 250,
    streak: 0,
    dataOstatniejAktywnosci: null,   // tak jest w bazie
);

Wynik: TypeError — argument $dataOstatniejAktywnosci musi być typu string, a podano null. W Lekcji 17 świadomie zdecydowaliśmy, że data_ostatniej_aktywnosci może być NULL (Ola jeszcze nic nie zrobiła). Klasa musi to umieć przyjąć.

Nowa składnia: typ z dopuszczalnym null. Znak zapytania przed typem oznacza „ten typ albo null”:

?string   // tekst albo null
?int      // liczba całkowita albo null

To bezpośredni odpowiednik kolumny NULL w bazie: DATE NULL → ?string, DATE NOT NULL → string.

Zmiany w classes/Uzytkownik.php (reszta klasy bez zmian):


  1. Jako pierwszą właściwość dopisz private int $id; — w Lekcji 17 zapowiadaliśmy, że id wróci, gdy będzie skąd je wziąć. Jutro (L19) będzie potrzebne do zapisu XP konkretnego użytkownika.



  2. private string $dataOstatniejAktywnosci; → private ?string $dataOstatniejAktywnosci;



  3. Konstruktor: nowy pierwszy parametr int $id, typ ostatniego ?string, w ciele $this->id = $id;:


    public function __construct(
    int $id,
    string $login,
    string $imieUzytkownika,
    int $xp,
    int $streak,
    ?string $dataOstatniejAktywnosci
    ) {
    $this->id = $id;
    $this->login = $login;
    // ... pozostałe przypisania bez zmian
    }


  4. Pod konstruktorem nowy getter:


    public function pobierzId(): int
    {
    return $this->id;
    }


  5. Metoda aktualizujStreak() przekazuje datę do funkcji aktualizujStreak(int $streak, string $dataOstatniejAktywnosci, bool $zadanieWykonane), która wymaga string. Dla null też byłby TypeError. Na początku ciała metody dopisz:


    public function aktualizujStreak(bool $zadanieWykonane): void
    {
    if ($this->dataOstatniejAktywnosci === null) {
    return; // brak aktywności — nie ma od czego liczyć serii, streak zostaje taki jak w bazie
    }

    $this->streak = aktualizujStreak($this->streak, $this->dataOstatniejAktywnosci, $zadanieWykonane);
    }

    Funkcja aktualizujStreak() z funkcje_terminow.php nie zmienia się. Problem null zatrzymujemy w klasie, zanim dotrze do funkcji.


Popraw brudnopis (id: 2, na początku argumentów) i uruchom ponownie: brak błędu. Dopisz var_dump($uzytkownik->pobierzId(), $uzytkownik->pobierzPoziom()); → int(2), int(3).

Twój index.php przestał teraz działać (new Uzytkownik(...) bez id) — to w porządku, w kroku 7 i tak zastąpimy ten fragment.

Krok 5 — SAMODZIELNIE: wczytajUzytkownika()

W funkcje_bazy.php napisz:

function wczytajUzytkownika(mysqli $polaczenie, int $uzytkownikId): ?Uzytkownik
  • pobiera z tabeli uzytkownicy jeden wiersz o podanym id — tylko kolumny potrzebne do obiektu (bez haslo_hash!),
  • zwraca obiekt Uzytkownik albo null, gdy takiego użytkownika nie ma (typ ?Uzytkownik — ten sam znak zapytania, tym razem przy klasie),
  • pamiętaj o konwencji nazw: kolumna imie → parametr imieUzytkownika, data_ostatniej_aktywnosci → dataOstatniejAktywnosci.

Test w brudnopisie (dołącz też classes/Uzytkownik.php):

var_dump(wczytajUzytkownika($polaczenie, 1)->pobierzImie());
var_dump(wczytajUzytkownika($polaczenie, 2)->pobierzXp());
var_dump(wczytajUzytkownika($polaczenie, 99));

Oczekiwane: string(4) "Kama", int(250), NULL.

HINT

Wiersz jest najwyżej jeden, więc pętla while nie jest potrzebna. Wystarczy jedno wywołanie fetch_assoc() — co zwróci, gdy wierszy nie ma wcale?

ROZWIĄZANIE
function wczytajUzytkownika(mysqli $polaczenie, int $uzytkownikId): ?Uzytkownik
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, login, imie, xp, streak, data_ostatniej_aktywnosci FROM uzytkownicy WHERE id = ?'
    );
    $zapytanie->bind_param('i', $uzytkownikId);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

    if ($wiersz === null) {
        return null;
    }

    return new Uzytkownik(
        id: $wiersz['id'],
        login: $wiersz['login'],
        imieUzytkownika: $wiersz['imie'],
        xp: $wiersz['xp'],
        streak: $wiersz['streak'],
        dataOstatniejAktywnosci: $wiersz['data_ostatniej_aktywnosci'],
    );
}

$zapytanie->get_result()->fetch_assoc() — dwie metody „w łańcuchu”: get_result() zwraca obiekt wyniku, a na nim od razu wywołujemy fetch_assoc(). To skrót od dwóch linii z $wynik.

Krok 6 — logowanie sprawdza bazę

6a. Funkcja znajdzIdUzytkownika() (funkcje_bazy.php)

Logowanie potrzebuje odpowiedzi na pytanie: „jakie id ma użytkownik o tym loginie — i czy w ogóle istnieje?”.

function znajdzIdUzytkownika(mysqli $polaczenie, string $login): ?int
{
    $zapytanie = $polaczenie->prepare('SELECT id FROM uzytkownicy WHERE login = ?');
    $zapytanie->bind_param('s', $login);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

    if ($wiersz === null) {
        return null;
    }

    return $wiersz['id'];
}

To dokładnie zapytanie z 3.5 — login pochodzi z formularza, więc prepared statement jest obowiązkowy. Sprawdź w brudnopisie: Kama_2026 → int(1), kama_2026 → int(1) (porównanie utf8mb4_unicode_ci nie rozróżnia wielkości liter — Test 2 z Lekcji 17), Nowy_1 → NULL.

6b. W sesji zostaje tylko uzytkownik_id

Od Lekcji 14 zapisywaliśmy w sesji login i imie. Teraz oba są w bazie. Gdybyśmy trzymali je także w sesji, mielibyśmy dwie kopie tych samych danych: zmiana imienia w bazie nie byłaby widoczna do ponownego logowania (to ta sama zasada „jedno źródło prawdy” co w Lekcjach 15 i 17). W sesji zapisujemy więc tylko identyfikator zalogowanego użytkownika, a resztę przy każdym żądaniu czytamy z bazy.

includes/funkcje_sesji.php — w czyZalogowany() zmień sprawdzany klucz:

function czyZalogowany(): bool
{
    return isset($_SESSION['uzytkownik_id']);
}

Przy okazji: jeśli ktoś ma otwartą sesję sprzed tej zmiany (z login, ale bez uzytkownik_id), zostanie po prostu przekierowany do logowania. Bez tej zmiany index.php próbowałby odczytać nieistniejący klucz.

6c. walidujDaneLogowania() sprawdza już tylko login

Imię jest w bazie, więc znika z formularza. W includes/funkcje_tekstowe.php zastąp funkcję wersją z jednym parametrem:

function walidujDaneLogowania(string $login): array
{
    $bledy = [];

    if (!walidujLogin($login)) {
        $bledy[] = 'Login musi mieć 3–20 znaków: litery bez polskich znaków, cyfry, _ lub -.';
    }

    return $bledy;
}

Funkcja nadal zwraca tablicę komunikatów (reguła z Lekcji 13). W Lekcji 19 dostanie drugi parametr — hasło.

6d. zaloguj.php

Zmiany w części PHP (na górze pliku):

  1. Do require_once dopisz config/config.php (na początku listy) i includes/funkcje_bazy.php (na końcu).
  2. Usuń zmienną $imieUzytkownika (wartość początkowa i odczyt z $_POST).
  3. Dodaj wartość początkową $bladBazy = null; obok $bledy = [];.
  4. Blok obsługi POST zastąp:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $login = trim($_POST['login'] ?? '');
    $bledy = walidujDaneLogowania($login);

    if ($bledy === []) {
        try {
            $polaczenie = polaczZBaza();
            $uzytkownikId = znajdzIdUzytkownika($polaczenie, $login);

            if ($uzytkownikId === null) {
                $bledy[] = 'Nie ma użytkownika o takim loginie.';
            } else {
                session_regenerate_id(true);
                $_SESSION['uzytkownik_id'] = $uzytkownikId;
                ustawKomunikat('Zalogowano. Powodzenia w zadaniach!');
                header('Location: index.php');
                exit;
            }
        } catch (Exception $wyjatek) {
            $bladBazy = $wyjatek->getMessage();
        }
    }
}

Zmiany w części HTML:

  1. Zdanie pod <h2>Logowanie</h2> → Wersja demonstracyjna: podaj login konta zapisanego w bazie — bez hasła.
  2. Usuń z formularza <label> i <input> pola imie.
  3. Pod ramką „Popraw formularz:” dodaj ramkę awarii (ten sam wzór co $bladZapisu w dodaj-zadanie.php):
<?php if ($bladBazy !== null) { ?>
    <p class="blad">Nie udało się zalogować: <?= $bladBazy; ?></p>
<?php } ?>

Zwróć uwagę na podział:

  • „nie ma takiego loginu” to błąd użytkownika → trafia do $bledy (tablica, pokazana w ramce „Popraw formularz”),
  • „baza nie odpowiada” to awaria → wyjątek → $bladBazy.

To ta sama reguła co od Lekcji 13: błąd użytkownika to nie wyjątek.

zaloguj.php nie dołącza klas — znajdzIdUzytkownika() zwraca liczbę, nie obiekt. Funkcje z funkcje_bazy.php, które tworzą obiekty, nie są tu wywoływane, więc brak klas nie przeszkadza.

Krok 7 — index.php czyta z bazy

Od tej chwili Twoja sesja z logowania „po staremu” nie wystarcza. Po zmianach wyloguj się i zaloguj ponownie.

7a. Dołączane pliki

Zamień require_once __DIR__ . '/includes/funkcje_plikow.php'; na require_once __DIR__ . '/includes/funkcje_bazy.php';. Strona główna nie czyta już CSV.

7b. Dane z bazy zamiast symulacji

Pod wymagajLogowania(); masz dziś dwa bloki: $uzytkownik = new Uzytkownik(...) z symulowanymi wartościami oraz try/catch z wczytajZadania(PLIK_ZADAN). Usuń oba i wstaw w ich miejsce:

// --- dane z bazy: zalogowany użytkownik i jego zadania ---
$uzytkownik = null;
$zadania = [];
$bladBazy = null;

try {
    $polaczenie = polaczZBaza();
    $uzytkownik = wczytajUzytkownika($polaczenie, $_SESSION['uzytkownik_id']);

    if ($uzytkownik !== null) {
        $zadania = wczytajZadaniaUzytkownika($polaczenie, $uzytkownik->pobierzId());
    }
} catch (Exception $wyjatek) {
    $bladBazy = $wyjatek->getMessage();
}

// --- awaria bazy: bez danych gracza nie ma czego pokazać ---
if ($bladBazy !== null) {
    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo "<p class=\"blad\">Nie udało się wczytać danych: $bladBazy</p>";
    require_once __DIR__ . '/includes/footer.php';
    exit;
}

// --- konto usunięte z bazy, a sesja jeszcze istnieje ---
if ($uzytkownik === null) {
    unset($_SESSION['uzytkownik_id']);
    header('Location: zaloguj.php');
    exit;
}

Dlaczego tak:

  • Jeden try na całą bazę. Użytkownik i zadania pochodzą z tego samego połączenia. Jeśli baza nie działa, nie mamy ani jednego, ani drugiego.
  • Przy awarii strona kończy się wcześnie. Do Lekcji 17 awaria dotyczyła tylko pliku z zadaniami, więc reszta panelu (XP, poziom, streak) mogła się wyświetlić. Teraz dane gracza też są w bazie — bez nich nie ma czego pokazać. Zamiast owijać cały szablon w if/else, wyświetlamy nagłówek, ramkę błędu, stopkę i kończymy skrypt (exit).
  • Sesja wskazuje na nieistniejące konto (np. ktoś usunął użytkownika w phpMyAdmin): usuwamy uzytkownik_id z sesji i odsyłamy do logowania (Test 7).
  • $_SESSION['uzytkownik_id'] czytamy bez ?? — wyżej jest wymagajLogowania(), a wartość zapisał nasz kod (konwencja z Lekcji 14).

Wszystko poniżej (listy zadań, liczniki, $zadanieWykonane, metody grywalizacji, $uzytkownik->aktualizujStreak($zadanieWykonane), filtr widoku, komunikat) zostaje bez zmian — dostaje obiekty tego samego typu co wcześniej.

7c. Prezentacja

  1. Sekcja „Twoje zadania” była od Lekcji 12 owinięta w <?php if ($bladZadan !== null) { ?> ... <?php } else { ?> ... <?php } ?>. Zmiennej $bladZadan już nie ma — awarię obsługuje blok z 7b. Usuń gałąź if z ramką błędu i same znaczniki else, zostaw zawartość gałęzi else.
  2. Usuń link <p><a href="dodaj-zadanie.php">+ Dodaj zadanie</a></p>. Formularz zapisuje jeszcze do CSV, więc dodane zadanie i tak nie pojawiłoby się na liście. Link wróci w Lekcji 19.

Reszta prezentacji bez zmian — „Witaj, …!”, „Zalogowano jako …”, XP i streak od Lekcji 15 pochodzą z metod $uzytkownik, więc same pokażą dane z bazy.

7d. Sprawdzenie

Wyloguj się, zaloguj jako Kama_2026. Strona powinna wyglądać tak jak przed lekcją (bez linku „+ Dodaj zadanie”). Potem zaloguj się jako Ola_TQ: 250 XP, poziom 3, jedno zadanie.

Krok 8 — diagnostyka.php i stopka

  1. W diagnostyka.php do $wymaganePliki dopisz 'includes/funkcje_bazy.php' (razem 19 ścieżek). Nic więcej — diagnostyka nadal nie łączy się z bazą (zasada z Lekcji 10 i 17: ma działać nawet przy zepsutym projekcie i bez config.php).
  2. includes/footer.php: TaskQuest 1.8.

6. Kolejne zadanie — ranking najlepszych graczy

Od pierwszych lekcji TaskQuest ma XP i poziomy, ale gracz nie wie, jak wypada na tle innych. Teraz, gdy w bazie jest wielu użytkowników, możemy to pokazać.

Pod listą zadań w index.php ma się pojawić:

Najlepsi gracze
1. Ola_TQ — 250 XP (poziom 3)
2. Kama_2026 — 99 XP (poziom 1)

Wymagania:

  1. W funkcje_bazy.php funkcja pobierzRanking(mysqli $polaczenie, int $limit): array, która zwraca tablicę wierszy z kluczami login i xp — najwyżej $limit graczy, od największego XP. Przy równym XP — alfabetycznie po loginie (żeby kolejność była zawsze ta sama).
  2. $limit przekazujemy parametrem zapytania (LIMIT ?), nie sklejamy.
  3. W index.php ranking dla 3 graczy wczytujemy w tym samym try co użytkownika i zadania (zmienna $ranking, wartość początkowa []).
  4. Poziom w rankingu liczy istniejąca funkcja — nie dodajemy kolumny poziom do zapytania (Lekcja 17, 3.5: poziomu nie ma w bazie).
  5. Sekcja <h2>Najlepsi gracze</h2> z listą numerowaną <ol>, na samym końcu panelu, po liście zadań.

Tu nie zwracamy obiektów Uzytkownik: ranking potrzebuje dwóch kolumn, a obiekt wymagałby wszystkich sześciu. Wynik zapytania, który służy tylko do wyświetlenia, może zostać tablicą.

HINT

ORDER BY może mieć kilka kolumn rozdzielonych przecinkiem — druga decyduje, gdy pierwsza ma równe wartości. Liczbę w LIMIT ? podpinasz tak samo jak id.

POMOC

Zamiast pętli while możesz pobrać wszystkie wiersze naraz:

return $wynik->fetch_all(MYSQLI_ASSOC);

fetch_all(MYSQLI_ASSOC) zwraca tablicę wszystkich wierszy (każdy jako tablica asocjacyjna); dla pustego wyniku []. Tam, gdzie z wiersza budujemy obiekt, zostajemy przy while — tu wiersze wystarczą.

ROZWIĄZANIE

includes/funkcje_bazy.php:

function pobierzRanking(mysqli $polaczenie, int $limit): array
{
    $zapytanie = $polaczenie->prepare('SELECT login, xp FROM uzytkownicy ORDER BY xp DESC, login LIMIT ?');
    $zapytanie->bind_param('i', $limit);
    $zapytanie->execute();

    return $zapytanie->get_result()->fetch_all(MYSQLI_ASSOC);
}

index.php — w bloku „dane z bazy” dopisz $ranking = []; pod $zadania = [];, a w try, w if ($uzytkownik !== null), pod wczytaniem zadań:

        $ranking = pobierzRanking($polaczenie, 3);

Na końcu treści panelu (po liście zadań, przed dołączeniem stopki):

<h2>Najlepsi gracze</h2>
<ol>
    <?php foreach ($ranking as $gracz) { ?>
        <li><?= $gracz['login']; ?> — <?= $gracz['xp']; ?> XP (poziom <?= obliczPoziom($gracz['xp']); ?>)</li>
    <?php } ?>
</ol>

obliczPoziom() przyjmuje int — i dostaje int, bo wynik pochodzi z prepared statementu (3.5).

7. Testy

Gdzie: przeglądarka (http://localhost/taskquest/...) i phpMyAdmin (baza taskquest → zakładka SQL albo edycja wiersza). Przed każdym testem zapisz przewidywany wynik. Po testach, które zmieniają dane, uruchom ponownie cały sql/taskquest.sql.

Test 1 — regresja: Kama widzi to samo co przed lekcją

Zaloguj się jako Kama_2026. Porównaj z taskquest_lekcja17/index.php uruchomionym tego samego dnia (tam zaloguj się jako Kama_2026 z imieniem Kama).

Oczekiwane: te same zadania, liczniki, XP 99, poziom 1, ten sam streak i te same komunikaty terminów. Różnice: stopka 1.8, brak linku „+ Dodaj zadanie”, nowa sekcja „Najlepsi gracze”. Jeśli w Lekcji 17 (Test 8) dodałaś/dodałeś zadanie przez formularz, jest ono w CSV, ale nie w bazie — w wersji 1.8 go nie zobaczysz. To zgodne z oczekiwaniami.

Test 2 — Ola widzi swoje dane

Wyloguj się, zaloguj jako Ola_TQ.

Co sprawdzaszOczekiwane
powitanie„Witaj, Ola!” (imię z bazy — w formularzu już go nie podajesz)
XP / poziom250 XP, poziom 3, do poziomu 4 brakuje 50 XP
streak0 (data_ostatniej_aktywnosci = NULL → metoda nie zmienia streaka)
zadaniadokładnie jedno: „Zaprojektować bazę TaskQuest”, niewykonane
rankingOla na 1. miejscu, Kama na 2.

Test 3 — logowanie: przypadki

Login w formularzuOczekiwane
Kama_2026zalogowano, strona główna, komunikat „Zalogowano. Powodzenia w zadaniach!”
kama_2026zalogowano jako Kama — „Zalogowano jako Kama_2026” (pisownia z bazy)
Nowy_1formularz z błędem „Nie ma użytkownika o takim loginie.”, pole wypełnione Nowy_1
Ka„Login musi mieć 3–20 znaków…” — do bazy w ogóle nie trafia zapytanie
' OR '1'='1ten sam komunikat o formacie loginu (walidujLogin() odrzuca apostrof i spację)

Przewidź: czy ' OR '1'='1 zalogowałby kogoś, gdyby usunąć walidację, a znajdzIdUzytkownika() zostawić z prepared statementem? (Odpowiedź: nie — patrz 3.5. Sprawdzisz to w Eksperymencie 1.)

Test 4 — dane żyją w bazie, nie w kodzie

Zalogowana/zalogowany jako Kama, w phpMyAdmin kliknij tabelę uzytkownicy → przy Kamie Edytuj → zmień xp na 150 i imie na Kamila → Wykonaj. Odśwież TaskQuest bez ponownego logowania.

Oczekiwane: „Witaj, Kamila!”, 150 XP, poziom 2, a w rankingu Kama nadal za Olą. Zmiana imienia jest widoczna od razu, bo w sesji jest tylko uzytkownik_id (6b). Zmień w phpMyAdmin tytuł jednego zadania Kamy i odśwież — nowy tytuł na liście. Uruchom ponownie taskquest.sql.

Test 5 — każdy widzi tylko swoje zadania

Zalogowana/zalogowany jako Ola, spróbuj wszystkich trzech widoków (?filtr=wszystkie, aktywne, wykonane). W żadnym nie ma zadań Kamy. Widok „wykonane” pokazuje „Brak zadań w tym widoku.”

Test 6 — baza nie działa

  1. Zalogowana/zalogowany jako Kama, zatrzymaj w XAMPP moduł MySQL i odśwież index.php.
    Oczekiwane: nagłówek, ramka „Nie udało się wczytać danych: Nie udało się połączyć z bazą danych.”, stopka. Żadnych danych gracza. (Jeśli nad ramką widzisz dodatkowo Warning, to ustawienia wyświetlania komunikatów PHP — wrócimy do tego w Lekcji 23.)
  2. Wyloguj się i spróbuj zalogować. Oczekiwane: formularz z ramką „Nie udało się zalogować: Nie udało się połączyć z bazą danych.”
  3. diagnostyka.php — działa normalnie (19 × [OK]), bo nie łączy się z bazą. Czy to dobrze, czy źle? Zapisz swoją odpowiedź — wrócisz do niej w pytaniach kontrolnych.
  4. Uruchom MySQL ponownie.

Test 7 — konto usunięte w trakcie sesji

Zaloguj się jako Ola. W phpMyAdmin: DELETE FROM uzytkownicy WHERE login = 'Ola_TQ';. Odśwież TaskQuest.

Oczekiwane: przekierowanie na stronę logowania (bez błędu PHP). Próba zalogowania jako Ola_TQ → „Nie ma użytkownika o takim loginie.” Uruchom ponownie taskquest.sql.

Test 8 — ranking i LIMIT

W phpMyAdmin:

INSERT INTO uzytkownicy (login, imie, xp) VALUES ('Kuba_TQ', 'Kuba', 180), ('Zosia_TQ', 'Zosia', 180);

Przewidź kolejność w rankingu, potem odśwież TaskQuest. Oczekiwane: Ola_TQ (250), Kuba_TQ (180), Zosia_TQ (180) — tylko trzy, Kama (99) nie mieści się w LIMIT. Kuba przed Zosią, bo przy równym XP decyduje login. Uruchom ponownie taskquest.sql.

Test 9 — diagnostyka

diagnostyka.php → 19 × [OK] (w tym includes/funkcje_bazy.php), sekcja „Baza danych” bez zmian.

8. Obowiązkowe eksperymenty

Gdzie: eksperymenty 1–2 w brudnopisie, 3–4 w projekcie. Po każdym przywróć zmieniony kod.

Eksperyment 1: prepared statement bez walidacji

W brudnopisie:

<?php

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

$polaczenie = polaczZBaza();
var_dump(znajdzIdUzytkownika($polaczenie, "' OR '1'='1"));
var_dump(znajdzIdUzytkownika($polaczenie, "Kama_2026' -- "));

Przewidź oba wyniki. Oczekiwane: NULL, NULL. Drugi ciąg to klasyczna próba „ucięcia” reszty zapytania komentarzem SQL (-- ). Przy sklejaniu dałaby zalogowanie jako Kama; w prepared statemencie to zwykły, nieistniejący login. Wniosek: walidacja w zaloguj.php jest wygodą i porządkiem, ale bezpieczeństwo zapytania zapewnia prepared statement.

Eksperyment 2: te same dane, dwa typy

W brudnopisie porównaj:

$wynik = $polaczenie->query('SELECT id FROM uzytkownicy WHERE login = \'Kama_2026\'');
$wierszZQuery = $wynik->fetch_assoc();

$login = 'Kama_2026';
$zapytanie = $polaczenie->prepare('SELECT id FROM uzytkownicy WHERE login = ?');
$zapytanie->bind_param('s', $login);
$zapytanie->execute();
$wierszZPrepare = $zapytanie->get_result()->fetch_assoc();

var_dump($wierszZQuery['id'], $wierszZPrepare['id']);
var_dump($wierszZQuery['id'] === 1, $wierszZPrepare['id'] === 1);

Oczekiwane: string(1) "1", int(1), a potem false, true. Gdyby TaskQuest używał query(), porównania === z liczbami przestałyby działać, a obiekty dostawałyby teksty zamiast liczb (bez błędu — tryb domyślny typów z Lekcji 15 po cichu zamienia '1' na 1). To jeszcze jeden powód, żeby w TaskQuest zawsze używać prepared statements.

Eksperyment 3: literówka w zapytaniu

W wczytajZadaniaUzytkownika() zmień w zapytaniu tytul na tytl i odśwież index.php.

Oczekiwane: ramka „Nie udało się wczytać danych: Unknown column 'tytl’ in 'field list’”. Programistę ten komunikat ratuje — od razu wiadomo, co poprawić. Ale użytkownik widzi nazwę kolumny, czyli fragment budowy bazy. Porównaj z komunikatem połączenia, który celowo ukryliśmy (Eksperyment 4). Na etapie nauki zostawiamy go widocznym; ukrywanie szczegółów przed użytkownikiem i zapis błędów do logu — Lekcja 22 i 23. Przywróć tytul.

Eksperyment 4: po co ukrywamy komunikat połączenia

  1. W config/config.php ustaw BAZA_HASLO na 'zlehaslo'. Odśwież index.php. Oczekiwane: „Nie udało się połączyć z bazą danych.”
  2. W polaczZBaza() tymczasowo zamień linię throw new Exception('Nie udało się połączyć z bazą danych.'); na throw $wyjatek; (ponowne rzucenie oryginalnego wyjątku). Odśwież.
    Oczekiwane: komunikat w rodzaju Access denied for user 'root'@'localhost' (using password: YES) — w przeglądarce użytkownika: nazwa konta bazy i adres serwera.
  3. Przywróć obie zmiany i sprawdź, że TaskQuest działa.

Eksperyment 5: filtr właściciela

W wczytajZadaniaUzytkownika() usuń z zapytania WHERE uzytkownik_id = ? oraz linię bind_param(...) (bez parametru nie ma czego podpinać). Zaloguj się jako Ola.

Oczekiwane: Ola widzi zadania Kamy. To dokładnie problem z Lekcji 17 („Kuba i Ola widzą te same zadania”) — tylko że tym razem to nie ograniczenie CSV, lecz błąd w zapytaniu. Baza oddaje to, o co prosimy; pilnowanie, czyje dane pokazujemy, należy do nas. Przywróć WHERE i bind_param().

9. ROZWIĄZANIE

Poniżej wszystkie pliki, które zmieniły się w Lekcji 18. Pliki niewymienione są identyczne jak po Lekcji 17 (w PHP: jak po Lekcji 15, wersja w 15_TaskQuest.md, sekcja 9; sql/taskquest.sql jak w 17_TaskQuest.md). Przy plikach, w których zmienia się tylko fragment, podany jest dokładny zakres zmiany.

ROZWIĄZANIE

config/config.php — dopisane na końcu

// (L18) dane dostępowe do bazy danych — domyślne ustawienia XAMPP
define('BAZA_HOST', 'localhost');
define('BAZA_UZYTKOWNIK', 'root');
define('BAZA_HASLO', '');
define('BAZA_NAZWA', 'taskquest');

includes/funkcje_bazy.php (nowy plik)

<?php

// Funkcje TaskQuest korzystające z bazy danych (Lekcja 18).
// Wymagają wcześniejszego dołączenia config/config.php (stałe BAZA_...).
// Funkcje tworzące obiekty wymagają też classes/Zadanie.php i classes/Uzytkownik.php.

function polaczZBaza(): mysqli
{
    mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);

    try {
        $polaczenie = new mysqli(BAZA_HOST, BAZA_UZYTKOWNIK, BAZA_HASLO, BAZA_NAZWA);
    } catch (Exception $wyjatek) {
        throw new Exception('Nie udało się połączyć z bazą danych.');
    }

    $polaczenie->set_charset('utf8mb4');

    return $polaczenie;
}

function wczytajZadaniaUzytkownika(mysqli $polaczenie, int $uzytkownikId): array
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, tytul, opis, termin, data_wykonania, xp FROM zadania WHERE uzytkownik_id = ? ORDER BY id'
    );
    $zapytanie->bind_param('i', $uzytkownikId);
    $zapytanie->execute();
    $wynik = $zapytanie->get_result();

    $zadania = [];

    while ($wiersz = $wynik->fetch_assoc()) {
        $zadania[] = new Zadanie(
            id: $wiersz['id'],
            tytul: $wiersz['tytul'],
            opis: $wiersz['opis'],
            termin: $wiersz['termin'],
            wykonane: $wiersz['data_wykonania'] !== null,
            xp: $wiersz['xp'],
        );
    }

    return $zadania;
}

function wczytajUzytkownika(mysqli $polaczenie, int $uzytkownikId): ?Uzytkownik
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, login, imie, xp, streak, data_ostatniej_aktywnosci FROM uzytkownicy WHERE id = ?'
    );
    $zapytanie->bind_param('i', $uzytkownikId);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

    if ($wiersz === null) {
        return null;
    }

    return new Uzytkownik(
        id: $wiersz['id'],
        login: $wiersz['login'],
        imieUzytkownika: $wiersz['imie'],
        xp: $wiersz['xp'],
        streak: $wiersz['streak'],
        dataOstatniejAktywnosci: $wiersz['data_ostatniej_aktywnosci'],
    );
}

function znajdzIdUzytkownika(mysqli $polaczenie, string $login): ?int
{
    $zapytanie = $polaczenie->prepare('SELECT id FROM uzytkownicy WHERE login = ?');
    $zapytanie->bind_param('s', $login);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

    if ($wiersz === null) {
        return null;
    }

    return $wiersz['id'];
}

function pobierzRanking(mysqli $polaczenie, int $limit): array
{
    $zapytanie = $polaczenie->prepare('SELECT login, xp FROM uzytkownicy ORDER BY xp DESC, login LIMIT ?');
    $zapytanie->bind_param('i', $limit);
    $zapytanie->execute();

    return $zapytanie->get_result()->fetch_all(MYSQLI_ASSOC);
}

classes/Uzytkownik.php — zmiany

  • nowa pierwsza właściwość private int $id;,
  • private ?string $dataOstatniejAktywnosci; (było string),
  • konstruktor: __construct(int $id, string $login, string $imieUzytkownika, int $xp, int $streak, ?string $dataOstatniejAktywnosci), pierwsza linia ciała $this->id = $id;,
  • nowa metoda pobierzId(): int (pod konstruktorem),
  • metoda aktualizujStreak(bool $zadanieWykonane): void — na początku ciała if ($this->dataOstatniejAktywnosci === null) { return; } (kod w kroku 4).

Pozostałe metody bez zmian.

includes/funkcje_sesji.php — czyZalogowany()

function czyZalogowany(): bool
{
    return isset($_SESSION['uzytkownik_id']);
}

includes/funkcje_tekstowe.php — walidujDaneLogowania()

function walidujDaneLogowania(string $login): array
{
    $bledy = [];

    if (!walidujLogin($login)) {
        $bledy[] = 'Login musi mieć 3–20 znaków: litery bez polskich znaków, cyfry, _ lub -.';
    }

    return $bledy;
}

zaloguj.php — część PHP (cała, do dołączenia header.php)

<?php

session_start();

require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/includes/funkcje_tekstowe.php';
require_once __DIR__ . '/includes/funkcje_sesji.php';
require_once __DIR__ . '/includes/funkcje_bazy.php';

if (czyZalogowany()) {
    header('Location: index.php');
    exit;
}

$login = '';
$bledy = [];
$bladBazy = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $login = trim($_POST['login'] ?? '');
    $bledy = walidujDaneLogowania($login);

    if ($bledy === []) {
        try {
            $polaczenie = polaczZBaza();
            $uzytkownikId = znajdzIdUzytkownika($polaczenie, $login);

            if ($uzytkownikId === null) {
                $bledy[] = 'Nie ma użytkownika o takim loginie.';
            } else {
                session_regenerate_id(true);
                $_SESSION['uzytkownik_id'] = $uzytkownikId;
                ustawKomunikat('Zalogowano. Powodzenia w zadaniach!');
                header('Location: index.php');
                exit;
            }
        } catch (Exception $wyjatek) {
            $bladBazy = $wyjatek->getMessage();
        }
    }
}

$nazwaStrony = 'Logowanie — TaskQuest';
require_once __DIR__ . '/includes/header.php';

część HTML (cała, od końca części PHP do końca pliku)

Wklej bezpośrednio pod linią require_once __DIR__ . '/includes/header.php';. Obie części razem to cały plik `zaloguj.php

?>

<h2>Logowanie</h2>
<p>Wersja demonstracyjna: podaj login konta zapisanego w bazie — bez hasła.</p>

<?php if ($bledy !== []) { ?>
    <div class="blad">
        <p>Popraw formularz:</p>
        <ul>
            <?php foreach ($bledy as $blad) { ?>
                <li><?= $blad; ?></li>
            <?php } ?>
        </ul>
    </div>
<?php } ?>

<?php if ($bladBazy !== null) { ?>
    <p class="blad">Nie udało się zalogować: <?= $bladBazy; ?></p>
<?php } ?>

<form method="post" action="zaloguj.php">
    <label for="login">Login</label>
    <input type="text" id="login" name="login" maxlength="20" required value="<?= $login; ?>">

    <button type="submit">Wejdź do TaskQuest</button>
</form>

<?php require_once __DIR__ . '/includes/footer.php'; ?>

index.php — zmiany

  1. require_once pliku includes/funkcje_plikow.php zastąpione require_once __DIR__ . '/includes/funkcje_bazy.php';.
  2. Bloki „dane użytkownika” (new Uzytkownik(...) z wartościami symulowanymi) i „dane zadań” (try/catch z wczytajZadania(PLIK_ZADAN)) zastąpione blokiem:
// --- dane z bazy: zalogowany użytkownik, jego zadania i ranking ---
$uzytkownik = null;
$zadania = [];
$ranking = [];
$bladBazy = null;

try {
    $polaczenie = polaczZBaza();
    $uzytkownik = wczytajUzytkownika($polaczenie, $_SESSION['uzytkownik_id']);

    if ($uzytkownik !== null) {
        $zadania = wczytajZadaniaUzytkownika($polaczenie, $uzytkownik->pobierzId());
        $ranking = pobierzRanking($polaczenie, 3);
    }
} catch (Exception $wyjatek) {
    $bladBazy = $wyjatek->getMessage();
}

// --- awaria bazy: bez danych gracza nie ma czego pokazać ---
if ($bladBazy !== null) {
    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo "<p class=\"blad\">Nie udało się wczytać danych: $bladBazy</p>";
    require_once __DIR__ . '/includes/footer.php';
    exit;
}

// --- konto usunięte z bazy, a sesja jeszcze istnieje ---
if ($uzytkownik === null) {
    unset($_SESSION['uzytkownik_id']);
    header('Location: zaloguj.php');
    exit;
}
  1. Wszystko od bloku wywołań (listy, liczniki, $zadanieWykonane, metody grywalizacji, streak) do komunikatu jednorazowego — bez zmian.
  2. Prezentacja: usunięty link „+ Dodaj zadanie”, usunięte if ($bladZadan !== null) { ... } else { wraz z zamknięciem (zawartość gałęzi else zostaje), na końcu panelu dodana sekcja:
<h2>Najlepsi gracze</h2>
<ol>
    <?php foreach ($ranking as $gracz) { ?>
        <li><?= $gracz['login']; ?> — <?= $gracz['xp']; ?> XP (poziom <?= obliczPoziom($gracz['xp']); ?>)</li>
    <?php } ?>
</ol>

diagnostyka.php

W $wymaganePliki dopisane 'includes/funkcje_bazy.php' (razem 19 ścieżek). Reszta bez zmian.

includes/footer.php

        <p>TaskQuest 1.8</p>
    </div>
</body>
</html>

10. Co zmieniliśmy w TaskQuest?

Zostaje

klasa Zadanie (bez zmian), wszystkie funkcje listy, grywalizacji, terminów, walidujLogin(), wyczyscTytul(), skrocOpis()
index.php: liczniki, filtr widoku + cookie, pasek XP, komunikat jednorazowy, lista z .po-terminie
sesja, session_regenerate_id(), wyloguj.php, wymagajLogowania(), ustawKomunikat(), pobierzKomunikat()
baza taskquest i sql/taskquest.sql (bez zmian)
dodaj-zadanie.php, data/zadania.csv, wczytajZadania(), zapiszZadania(), sprawdzWierszZadania(), PLIK_ZADAN
    — zostają do Lekcji 19 (formularz nadal zapisuje do CSV; wyłączony z nawigacji)
$zadanieWykonane — nadal uproszczenie (prawdziwy streak w L19)

Dochodzi

config.php:           BAZA_HOST, BAZA_UZYTKOWNIK, BAZA_HASLO, BAZA_NAZWA
includes/funkcje_bazy.php:
    polaczZBaza(): mysqli
    wczytajZadaniaUzytkownika(mysqli, int): array        — tablica obiektów Zadanie
    wczytajUzytkownika(mysqli, int): ?Uzytkownik
    znajdzIdUzytkownika(mysqli, string): ?int
    pobierzRanking(mysqli, int): array                   — wiersze ['login', 'xp']
Uzytkownik: właściwość id, pobierzId()
$_SESSION['uzytkownik_id']
index.php: $bladBazy, $ranking, sekcja „Najlepsi gracze”
zaloguj.php: $bladBazy, $uzytkownikId

Nowe narzędzia: SQL SELECT kolumny FROM ... WHERE ... AND ... IS NULL ... ORDER BY ... DESC ... LIMIT; mysqli_report(), new mysqli(...), set_charset(), query(), prepare(), bind_param(), execute(), get_result(), fetch_assoc(), fetch_all(MYSQLI_ASSOC), num_rows; typy ?string, ?int, ?Klasa; pojęcie SQL injection.

Zmieniamy

  • Uzytkownik: dataOstatniejAktywnosci typu ?string; konstruktor z id na początku; aktualizujStreak() nic nie robi, gdy data to null,
  • czyZalogowany(): sprawdza uzytkownik_id,
  • walidujDaneLogowania(): jeden parametr (login),
  • zaloguj.php: login musi istnieć w bazie; formularz bez imienia,
  • index.php: dane z bazy, obsługa awarii bazy i usuniętego konta, bez linku do formularza, ranking,
  • diagnostyka.php: 19 plików; stopka 1.8.

Usuwamy

CoDlaczego
wartości symulowane xp: 99, streak: 5, dataOstatniejAktywnosci: '2026-09-14' w index.phpdane gracza są w bazie; te same wartości ma Kama w taskquest.sql
$bladZadan i owinięcie sekcji zadań w if/elseawarię bazy obsługuje $bladBazy dla całej strony
$_SESSION['login'], $_SESSION['imie']kopie danych z bazy mogłyby się rozjechać; w sesji tylko identyfikator
pole „imię” w formularzu logowania i $imieUzytkownika w zaloguj.phpimię jest w bazie; użytkownik nie „deklaruje” go przy logowaniu
reguły imienia w walidujDaneLogowania()j.w.
dołączanie funkcje_plikow.php w index.phpstrona główna nie czyta CSV
link „+ Dodaj zadanie” (tymczasowo)formularz zapisuje do CSV — zadanie nie pojawiłoby się na liście; wraca w L19

Co nowego potrafi TaskQuest po tej lekcji?

Po Lekcji 18 TaskQuest łączy się z bazą danych i czyta z niej wszystko, co pokazuje na stronie głównej. Logowanie sprawdza, czy konto istnieje. Każdy użytkownik widzi własne imię, XP, poziom, streak i wyłącznie własne zadania, a ranking pokazuje trzech najlepszych graczy. Wszystkie zapytania z danymi z zewnątrz są odporne na SQL injection dzięki prepared statements. Gdy baza nie działa, użytkownik dostaje czytelny komunikat zamiast błędu PHP.

11. Zadania końcowe — rozbuduj TaskQuest samodzielnie

Nie są częścią wersji referencyjnej.

A. Wyszukiwarka zadań. Nad listą zadań formularz method="get" z polem szukaj. Po wysłaniu lista pokazuje tylko zadania zalogowanego użytkownika, których tytuł zawiera wpisany tekst. Nowa funkcja szukajZadanUzytkownika(mysqli $polaczenie, int $uzytkownikId, string $fraza): array z warunkiem WHERE uzytkownik_id = ? AND tytul LIKE ?. Znaki % wokół frazy dodaj w PHP przed bind_param() ('%' . $fraza . '%'), nie w tekście zapytania. Przetestuj frazy: PHP, php (czy wielkość liter ma znaczenie? dlaczego?), ' OR '1'='1, pusty tekst. Pytanie dodatkowe: co znajdzie fraza %? Czy to błąd bezpieczeństwa, czy tylko niespodzianka?

B. Statystyki liczone przez bazę. Jednym zapytaniem pobierz dla zalogowanego użytkownika liczbę aktywnych zadań i sumę ich XP: SELECT COUNT(*) AS liczba, SUM(xp) AS suma FROM zadania WHERE uzytkownik_id = ? AND data_wykonania IS NULL. Wyświetl obok liczników z PHP i sprawdź, czy wyniki są równe $liczbaAktywnych i $xpDoZdobycia. Co zwraca SUM(xp), gdy aktywnych zadań nie ma (zaloguj się jako użytkownik bez zadań z Testu 8)? Kiedy liczenie w bazie jest lepsze niż w PHP?

C. Moje miejsce w rankingu. Pod rankingiem: „Twoje miejsce: N”. Podpowiedź: miejsce to liczba graczy z większym XP plus 1 (SELECT COUNT(*) ... WHERE xp > ?). Dodatkowo wyróżnij (<strong>) wiersz zalogowanego użytkownika w rankingu. Jak potraktujesz dwóch graczy z równym XP?

Sprzątanie

  1. Usuń brudnopis.php.
  2. Sprawdź, że przywrócone są: tytul w zapytaniu, BAZA_HASLO = '', throw new Exception(...) w polaczZBaza(), WHERE uzytkownik_id = ? i bind_param() (Eksperymenty 3–5).
  3. Uruchom ponownie sql/taskquest.sql.
  4. Zaloguj się jako Kama i jako Ola — Testy 1 i 2 mają przechodzić.

Do samodzielnego sprawdzenia

[ ] Nadal rozwijam ten sam projekt TaskQuest (nie zaczynam nowego).
[ ] Potrafię napisać SELECT z wybranymi kolumnami, WHERE, AND, IS NULL, ORDER BY i LIMIT.
[ ] Wiem, dlaczego WHERE data_wykonania = NULL zwraca zero wierszy.
[ ] Potrafię połączyć się z bazą obiektem mysqli i wiem, co robi mysqli_report(...STRICT).
[ ] Rozróżniam $polaczenie, $zapytanie, $wynik i $wiersz.
[ ] Potrafię pokazać na przykładzie, na czym polega SQL injection.
[ ] Potrafię napisać prepared statement z jednym i z dwoma parametrami (typy s, i).
[ ] Wiem, dlaczego z prepared statementu id przychodzi jako int, a z query() jako tekst.
[ ] Potrafię zamienić wiersz z bazy w obiekt Zadanie / Uzytkownik.
[ ] Wiem, co znaczy ?string i dlaczego Uzytkownik go potrzebuje.
[ ] Wiem, dlaczego w sesji jest tylko uzytkownik_id.
[ ] Kama i Ola widzą po zalogowaniu swoje dane; ranking działa.
[ ] Przy wyłączonej bazie TaskQuest pokazuje komunikat, a nie błąd PHP.
[ ] Usunęłam/usunąłem brudnopis.

Pytania kontrolne


Co zwróci każde zapytanie (liczba wierszy), gdy w bazie są tylko dane testowe z taskquest.sql?

SELECT tytul FROM zadania WHERE uzytkownik_id = 2 AND data_wykonania IS NOT NULL;
SELECT login FROM uzytkownicy ORDER BY xp LIMIT 1;
SELECT tytul FROM zadania WHERE data_wykonania = NULL;

Kolega napisał: $polaczenie->query("SELECT id FROM uzytkownicy WHERE login = '$login'") i twierdzi, że to bezpieczne, bo $login przeszedł walidujLogin(). Czy ma rację w tym jednym miejscu? Dlaczego mimo to w TaskQuest tak nie piszemy?

Znajdź dwa błędy:
$zapytanie = $polaczenie->prepare('SELECT id, tytul FROM zadania WHERE uzytkownik_id = ? AND termin < ?');
$zapytanie->bind_param('i', $uzytkownikId, $dzisiaj);
$zapytanie->execute();
while ($wiersz = $zapytanie->fetch_assoc()) { ... }

wczytajUzytkownika($polaczenie, 7) zwraca null. Czy to awaria? Czym ta sytuacja różni się od wyłączonego serwera MySQL i jak każdą z nich obsługuje index.php?

Dlaczego polaczZBaza() łapie wyjątek i rzuca nowy z innym komunikatem, a wczytajZadaniaUzytkownika() nie łapie niczego?

W sesji jest tylko uzytkownik_id. Nauczycielka zmienia w phpMyAdmin imię ucznia. Kiedy uczeń zobaczy nowe imię — i jak byłoby, gdyby imię nadal było w sesji?

Dlaczego właściwość dataOstatniejAktywnosci ma typ ?string, a login zwykły string? Wskaż kolumny w taskquest.sql, które o tym decydują.

Ranking zwraca tablice ['login' => ..., 'xp' => ...], a wczytajUzytkownika() obiekt Uzytkownik. Dlaczego ta różnica jest uzasadniona? Skąd ranking bierze poziom gracza, skoro w zapytaniu go nie ma?

Bez WHERE uzytkownik_id = ? Ola widzi zadania Kamy (Eksperyment 5). Czy prepared statement chroni przed tym błędem? Czym ten błąd różni się od SQL injection?

Przy wyłączonym MySQL diagnostyka.php pokazuje 19 × [OK]. Czy diagnostyka powinna sprawdzać połączenie z bazą? Podaj argument za i przeciw (wskazówka: zasada z Lekcji 10 i miejsce, w którym są dane dostępowe).

PODSUMOWANIE LEKCJI

W skrócie

Cztery różne rzeczy, których nie wolno mylić:

$polaczenie   obiekt mysqli            — połączenie z serwerem
$zapytanie    obiekt mysqli_stmt       — przygotowane polecenie (prepare)
$wynik        obiekt mysqli_result     — zbiór wierszy do odczytania
$wiersz       tablica asocjacyjna      — jeden wiersz; null, gdy wierszy już nie ma

Metody nie są wymienne: num_rows i fetch_assoc() należą do wyniku, bind_param() i execute() do zapytania, prepare() i set_charset() do połączenia.

SQL:

  • Kolejność klauzul jest stała: SELECT ... FROM ... WHERE ... ORDER BY ... LIMIT. Zamiana miejscami = błąd składni.
  • Do NULL tylko IS NULL / IS NOT NULL. = NULL zwraca zero wierszy bez żadnego błędu — to najtrudniejszy do znalezienia z tych błędów.
  • Wymieniaj kolumny zamiast SELECT * — nie przeciągasz do PHP danych, których nie potrzebujesz (np. haslo_hash).
  • Zero wierszy to poprawny wynik, nie awaria. Awaria to brak połączenia albo błąd składni.

Bezpieczeństwo:

  • Każda wartość pochodząca z zewnątrz ($_GET, $_POST, cookie, sesja) trafia do zapytania tylko przez prepared statement. Sklejanie "... = '$login'" jest zakazane.
  • Walidacja to pierwsza linia obrony, nie zabezpieczenie zapytania. Zapytanie ma być bezpieczne samo z siebie.
  • Przez ? / :nazwa przekazujesz wartości, nigdy nazw tabel, nazw kolumn ani kierunku sortowania. Tam działa wyłącznie whitelist (in_array() + wartość domyślna).
  • Prepared statement chroni przed SQL injection, ale nie przed brakiem WHERE uzytkownik_id = ?. Cudze dane pokazane właściwemu zapytaniu to błąd logiki, nie podatności.
  • htmlspecialchars() przy wyświetlaniu — to osobna ochrona (XSS), niezależna od prepared statements.

Szczegóły, które zaskakują:

  • query() zwraca kolumny INT jako teksty, prepared statement (get_result()) — jako int. Daty zawsze jako tekst RRRR-MM-DD, NULL jako null.
  • bind_param() przyjmuje zmienne, nie wartości: bind_param('i', 5) kończy się błędem.
  • Liczba liter w typach musi być równa liczbie znaków ?.
  • Znaki % przy LIKE dodajesz w PHP do zmiennej, nie do tekstu zapytania.
  • W PDO liczbę wierszy zwróconych przez SELECT liczymy przez count($wyniki) po fetchAll(), a fetch() po ostatnim wierszu zwraca false (w mysqli fetch_assoc() zwraca null).

Co trzeba umieć na INF.03?

  • połączyć skrypt z bazą na warunkach podanych w arkuszu: localhost, użytkownik root bez hasła, wskazana nazwa bazy;
  • zamknąć połączenie na końcu skryptu — arkusz wymienia to jako osobne wymaganie wobec skryptów, więc brak zamknięcia to strata punktu, mimo że PHP zamyka połączenie sam;
  • napisać SELECT z wybranymi kolumnami i warunkiem WHERE (arkusz: „wybierające jedynie pola idObiekt, panstwo, nazwa, wartoscCechy … dla idRodzaj = 10 i idKontynent = 6”) i zapisać zapytania w pliku tekstowym;
  • wypełnić tabelę HTML wierszami z bazy — pętla po wyniku, w każdym obrocie jeden <tr> z komórkami z kolejnych pól;
  • wypełnić listę rozwijaną danymi z bazy — <option value="idTurysta">nick</option> dla każdego wiersza;
  • wygenerować odnośniki z parametrem GET na podstawie wyniku zapytania (index.php?idKontynent=6) i odebrać ten parametr z wartością domyślną, gdy go nie ma;
  • rozpoznać w arkuszu tabelę funkcji mysqli_connect, mysqli_query, mysqli_fetch_row, mysqli_fetch_array, mysqli_num_rows, mysqli_num_fields, mysqli_error, mysqli_close, isset i umieć z niej skorzystać.

Minimum, które powinno wejść w pamięć

Jeżeli masz zapamiętać tylko kilka rzeczy z tej lekcji, zapamiętaj te:

  1. SELECT kolumny FROM tabela WHERE warunek ORDER BY kolumna LIMIT n — kolejność stała.
  2. IS NULL / IS NOT NULL. = NULL zawsze daje zero wierszy i nie zgłasza błędu.
  3. Schemat obiektowy: new mysqli(...) → prepare('... WHERE x = ?') → bind_param('i', $x) → execute() → get_result() → while ($wiersz = $wynik->fetch_assoc()).
  4. Typy w bind_param(): s tekst, i liczba całkowita, d liczba z przecinkiem. Tyle liter, ile znaków ?, w tej samej kolejności.
  5. ? zastępuje wartość. Nazwa kolumny w ORDER BY idzie przez whitelistę.
  6. Dane z formularza lub adresu nigdy nie są doklejane do tekstu zapytania.
  7. fetch_assoc() zwraca kolejny wiersz albo null — dlatego działa while ($wiersz = ...).
  8. Przy wyświetlaniu danych z bazy: htmlspecialchars().
  9. Na egzaminie: localhost, root, bez hasła, na końcu zamknięcie połączenia.

Ściągawka – najważniejsze polecenia i składnia

Połączenie — obiektowo, błędy jako wyjątki (styl TaskQuest)

mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
$polaczenie = new mysqli('localhost', 'root', '', 'nazwa_bazy');
$polaczenie->set_charset('utf8mb4');
// ...
$polaczenie->close();

Połączenie — obiektowo, błąd sprawdzany warunkiem (styl z części teoretycznej)

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'nazwa_bazy');
if ($db->connect_error) { die("Błąd połączenia: {$db->connect_error}"); }

Połączenie — PDO

$pdo = new PDO("mysql:host=localhost;dbname=nazwa_bazy;charset=utf8mb4", 'root', '', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);
$pdo = null;   // zamknięcie

SELECT

SELECT tytul, termin FROM zadania
WHERE uzytkownik_id = 1 AND data_wykonania IS NULL
ORDER BY termin DESC
LIMIT 10;

Odczyt wyniku — mysqli

$zapytanie = $polaczenie->prepare('SELECT id, tytul FROM zadania WHERE uzytkownik_id = ?');
$zapytanie->bind_param('i', $uzytkownikId);
$zapytanie->execute();
$wynik = $zapytanie->get_result();

echo $wynik->num_rows;
while ($wiersz = $wynik->fetch_assoc()) {
    echo htmlspecialchars($wiersz['tytul']);
}
$zapytanie->close();

Odczyt wyniku — PDO

$stmt = $pdo->prepare('SELECT id, tytul FROM zadania WHERE uzytkownik_id = :id');
$stmt->execute(['id' => $uzytkownikId]);

$wiersze = $stmt->fetchAll();     // wszystkie naraz -> count($wiersze)
$wiersz  = $stmt->fetch();        // po jednym; false, gdy koniec

Dwa parametry, LIKE, LIMIT, agregacje

$zapytanie = $polaczenie->prepare('SELECT id FROM zadania WHERE uzytkownik_id = ? AND tytul LIKE ?');
$fraza = '%' . $szukane . '%';                 // % w PHP, nie w SQL
$zapytanie->bind_param('is', $uzytkownikId, $fraza);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);   // LIMIT w PDO wymaga typu
SELECT COUNT(*) AS liczba, SUM(xp) AS suma, AVG(xp) AS srednia, MAX(xp) AS maks
FROM zadania WHERE uzytkownik_id = 1;

Whitelist dla sortowania

$sort = $_GET['sort'] ?? 'xp';
$dozwolone = ['xp', 'login', 'streak'];
if (!in_array($sort, $dozwolone)) { $sort = 'xp'; }
$sql = "SELECT login, xp FROM uzytkownicy ORDER BY {$sort} DESC";

Mapowanie: zapis obiektowy ↔ funkcje z arkusza egzaminacyjnego

ObiektowoProceduralnie (tabela z arkusza)
new mysqli($h, $u, $p, $b)mysqli_connect($h, $u, $p, $b)
$db->query($sql)mysqli_query($db, $sql)
$wynik->fetch_assoc()mysqli_fetch_array($wynik)
— (odpowiednik: fetch_row())mysqli_fetch_row($wynik) — tablica numeryczna
$wynik->num_rowsmysqli_num_rows($wynik)
$db->errormysqli_error($db)
$db->close()mysqli_close($db)

mysqli_fetch_array() domyślnie zwraca klucze i numeryczne, i tekstowe (MYSQLI_BOTH) — stąd w arkuszu działa zarówno $w['nazwa'], jak i $w[1].


Quiz – sprawdź się

  1. Ile wierszy zwróci każde zapytanie, jeżeli w tabeli zadania są 4 wiersze Kamy (w tym 1 wykonane) i 1 wiersz Oli (niewykonany)?SELECT tytul FROM zadania WHERE uzytkownik_id = 1 AND data_wykonania IS NULL;
    SELECT tytul FROM zadania WHERE data_wykonania = NULL;
    SELECT tytul FROM zadania ORDER BY termin LIMIT 2;

  2. Dlaczego to zapytanie nie zadziała i jak je poprawić?SELECT login FROM uzytkownicy ORDER BY xp DESC WHERE xp > 100;

  3. Co jest źle?$zapytanie = $polaczenie->prepare("SELECT id FROM uzytkownicy WHERE login = '?'");
    $zapytanie->bind_param('s', $login);

  4. Znajdź dwa błędy:$zapytanie = $polaczenie->prepare('SELECT id, tytul FROM zadania WHERE uzytkownik_id = ? AND xp > ?');
    $zapytanie->bind_param('i', $uzytkownikId, $minimumXp);
    $zapytanie->execute();
    while ($wiersz = $zapytanie->fetch_assoc()) { echo $wiersz['tytul']; }

  5. Co wypisze ten kod, jeżeli zapytanie zwróci 3 wiersze?$wynik = $polaczenie->query('SELECT login FROM uzytkownicy');
    $ile = 0;
    while ($wiersz = $wynik->fetch_assoc()) { $ile++; }
    echo $ile + $wynik->num_rows;

  6. Użytkownik wpisuje w wyszukiwarkę ' OR '1'='1. Wersja A używa query() ze sklejonym tekstem, wersja B prepared statement. Ile wierszy zwróci każda i dlaczego?

  7. Uzupełnij tak, aby znaleźć zadania użytkownika o id = 3, których tytuł zawiera słowo wpisane w $szukane:$zapytanie = $polaczenie->prepare('SELECT tytul FROM zadania WHERE uzytkownik_id = ? AND tytul LIKE ?');
    $fraza = ______________;
    $zapytanie->bind_param(______, $id, $fraza);

  8. Kolega chce pozwolić użytkownikowi wybrać kolumnę sortowania i pisze:$zapytanie = $polaczenie->prepare('SELECT login, xp FROM uzytkownicy ORDER BY ?');
    $zapytanie->bind_param('s', $_GET['sort']);
    Czy to jest bezpieczne? Czy to zadziała? Jak zrobić to poprawnie?

  9. Ten sam wiersz odczytany raz przez query(), raz przez prepared statement. Co pokaże var_dump($wiersz['xp']) w każdym przypadku i skąd ta różnica?

  10. wczytajUzytkownika($polaczenie, 7) zwraca null, bo użytkownika o takim id nie ma. Czy skrypt powinien rzucić wyjątek? Uzasadnij.

  11. Który fragment jest niebezpieczny i jaki to typ podatności?// A
    $stmt = $polaczenie->prepare('SELECT imie FROM uzytkownicy WHERE id = ?');
    $stmt->bind_param('i', $id);
    $stmt->execute();
    $wiersz = $stmt->get_result()->fetch_assoc();
    echo "Cześć, {$wiersz['imie']}!";

    // B
    $imie = $_POST['imie'];
    echo "Cześć, " . htmlspecialchars($imie) . "!";

  12. Zadanie praktyczne (schemat egzaminacyjny). Napisz skrypt, który: łączy się z bazą wodospady (localhost, root, bez hasła), pobiera idObiekt, panstwo, nazwa z tabeli obiekty dla idKontynent przesłanego metodą GET (domyślnie 6), wypisuje każdy rekord jako wiersz tabeli HTML i na końcu zamyka połączenie. Wypisz sam szkielet PHP, bez stylów.


Odpowiedzi
  1. 3 wiersze; 0 wierszy (mimo że niewykonane zadania istnieją — = NULL nigdy nie jest prawdą); 2 wiersze.

  2. Zła kolejność klauzul. Poprawnie: SELECT login FROM uzytkownicy WHERE xp > 100 ORDER BY xp DESC;

  3. '?' w apostrofach to zwykły tekst, a nie parametr. Zapytanie ma zero parametrów, więc bind_param() zgłosi błąd o niezgodnej liczbie parametrów. Poprawnie: WHERE login = ?.

  4. (a) Typy: dwa znaki ?, a tylko jedna litera — powinno być bind_param('ii', $uzytkownikId, $minimumXp). (b) fetch_assoc() wywołane na zapytaniu zamiast na wyniku — brakuje $wynik = $zapytanie->get_result(); i pętli po $wynik.

  5. 6. Pętla odczytała 3 wiersze, więc $ile wynosi 3. num_rows to liczba wierszy w wyniku i nie zmniejsza się w miarę odczytywania — nadal 3. Razem 6.

  6. Wersja A zwróci wszystkie wiersze — tekst użytkownika stał się częścią polecenia i warunek '1'='1' jest zawsze prawdziwy (SQL injection). Wersja B zwróci 0 — baza porówna to jako zwykły tekst, czyli poszuka nazwy dosłownie brzmiącej ' OR '1'='1.

  7. $fraza = '%' . $szukane . '%'; oraz bind_param('is', $id, $fraza).

  8. Zadziała bez błędu, ale nie posortuje — parametr zostanie wstawiony jako wartość tekstowa, czyli sortowanie odbędzie się po stałym tekście. Przez ? przekazujemy tylko wartości. Poprawnie: whitelist (in_array() + wartość domyślna) i wstawienie nazwy kolumny do tekstu zapytania.

  9. Po query(): string(2) "99". Po prepared statement z get_result(): int(99). Prepared statements przesyłają wynik protokołem binarnym i mysqli zamienia kolumny INT na PHP-owe int.

  10. Nie. Brak wiersza to normalny wynik zapytania, nie awaria — funkcja zwraca null, a kod wywołujący decyduje, co z tym zrobić (np. wylogowanie i komunikat). Wyjątek zostaw na sytuacje, w których nie da się wykonać zapytania: brak połączenia, błąd SQL.

  11. Fragment A. Zapytanie jest bezpieczne (prepared statement), ale imię pobrane z bazy trafia do HTML bez htmlspecialchars() — jeżeli ktoś wcześniej zapisał do bazy <script>, dostajemy XSS. Baza nie jest źródłem „zaufanych” danych. Fragment B jest poprawny.

  12. Szkielet (obiektowo; wersja proceduralna z arkusza analogicznie):<?php
    $polaczenie = new mysqli('localhost', 'root', '', 'wodospady');
    $idKontynent = $_GET['idKontynent'] ?? 6;

    $zapytanie = $polaczenie->prepare(
    'SELECT idObiekt, panstwo, nazwa FROM obiekty WHERE idKontynent = ?'
    );
    $zapytanie->bind_param('i', $idKontynent);
    $zapytanie->execute();
    $wynik = $zapytanie->get_result();

    while ($wiersz = $wynik->fetch_assoc()) {
    echo '<tr>';
    echo '<td>' . $wiersz['idObiekt'] . '</td>';
    echo '<td>' . htmlspecialchars($wiersz['panstwo']) . '</td>';
    echo '<td>' . htmlspecialchars($wiersz['nazwa']) . '</td>';
    echo '</tr>';
    }

    $polaczenie->close();
    ?>
    Punkty, o których najłatwiej zapomnieć: wartość domyślna dla parametru GET, <tr> wewnątrz pętli (a nie przed nią) i zamknięcie połączenia na końcu.