19. PHP – skrypty zmieniające dane w bazie danych – INSERT, UPDATE, DELETE


Szybki quiz – przypomnienie poprzedniej lekcji

Lekcja 18: połączenie z bazą, SELECT, prepared statements, SQL injection.

  1. To zapytanie nie zwraca żadnego wiersza, choć w tabeli są zadania bez daty wykonania. Dlaczego i jak je poprawić?SELECT tytul FROM zadania WHERE data_wykonania = NULL;

  2. Znajdź błąd:$zapytanie = $polaczenie->prepare("SELECT id, login FROM uzytkownicy WHERE login = '?'");
    $zapytanie->bind_param('s', $login);

  3. Zapytanie ma warunek WHERE uzytkownik_id = ? AND termin < ? AND xp >= ? (kolumny kolejno: liczba, data, liczba). Jaki będzie pierwszy argument bind_param()?

  4. Co zwraca fetch_assoc(), gdy wiersze się skończyły? Czym różni się od num_rows?

  5. Użytkownik wpisuje w pole wyszukiwania ' OR '1'='1. Co się stanie, gdy tekst zostanie wklejony do zapytania SQL, a co gdy trafi tam przez bind_param()?

  6. wczytajUzytkownika($polaczenie, 99) zwraca null. Czy to awaria bazy? Uzasadnij.

Odpowiedzi
  1. Porównanie z NULL przez = nigdy nie daje prawdy — NULL znaczy „nie wiadomo”, więc warunek nie jest spełniony dla żadnego wiersza, a baza nie zgłasza błędu. Poprawnie: WHERE data_wykonania IS NULL.
  2. '?' w apostrofach to zwykły tekst, a nie parametr. Zapytanie nie ma żadnego ?, więc bind_param() zgłosi błąd o niezgodnej liczbie parametrów. Poprawnie: WHERE login = ?.
  3. 'isi' — kolejno: liczba całkowita, data (przekazywana jako tekst), liczba całkowita.
  4. fetch_assoc() zwraca kolejny wiersz jako tablicę asocjacyjną, a po ostatnim — null. num_rows to liczba wierszy w całym wyniku; niczego z niego nie pobiera.
  5. Przy sklejaniu SQL tekst staje się częścią polecenia — warunek '1'='1' jest zawsze prawdziwy i zapytanie zwraca wszystkich użytkowników (SQL injection). Przy bind_param() to tylko dziwny login, którego nikt nie ma: 0 wierszy, bez błędu.
  6. Nie. Zapytanie wykonało się poprawnie, po prostu żaden wiersz nie pasował. Awaria (np. brak bazy) to wyjątek mysqli_sql_exception, a nie null.
  7. Przez ? przekazujemy wartości, nie nazwy kolumn — nazwa trafiłaby tam jako tekst i nic by nie posortowała. Nazwę kolumny sprawdzamy białą listą dozwolonych nazw (whitelist).

TaskQuest

Dodatek

W poprzednim materiale nauczyłeś się pobierać dane z bazy używając zapytań SELECT. Teraz czas na operacje, które zmieniają dane:

  • INSERT – dodawanie nowych rekordów (np. rejestracja gracza, dodanie posta)
  • UPDATE – aktualizacja istniejących danych (np. zmiana poziomu, edycja profilu)
  • DELETE – usuwanie rekordów (np. usunięcie konta, skasowanie komentarza)

Te operacje są fundamentem każdej aplikacji:

  • Rejestracja użytkownika → INSERT
  • Aktualizacja avatara → UPDATE
  • Usunięcie spamu → DELETE
  • System komentarzy → INSERT + UPDATE + DELETE

UWAGA: Operacje zmieniające dane są nieodwracalne! Zawsze używaj prepared statements i walidacji danych.

Będziemy pracować na bazie gaming_hub z poprzedniego materiału. Upewnij się, że masz utworzoną tabelę players

UWAGA: Przedstawione poniżej przykłady nie są zabezpieczone przed odświeżaniem strony (F5) lub ponownym kliknięciem przycisku zmieniającego dane. Każde odświeżenie strony będzie powodowało ponowne wykonanie operacji na bazie. Jak to zrobić opiszę na końcu materiału.

INSERT – Dodawanie nowych rekordów

Przykład 1: Rejestracja gracza (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');

$errors = [];
$success = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Pobieramy i czyszczenie dane z formularza
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    $password_confirm = $_POST['password_confirm'] ?? '';
    
    // Walidacja danych
    if (empty($username)) {
        $errors[] = "Nick jest wymagany";
    } elseif (strlen($username) < 3) {
        $errors[] = "Nick musi mieć minimum 3 znaki";
    } elseif (strlen($username) > 50) {
        $errors[] = "Nick może mieć maksymalnie 50 znaków";
    } elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
        $errors[] = "Nick może zawierać tylko litery, cyfry i podkreślnik";
    }
    
    if (empty($email)) {
        $errors[] = "Email jest wymagany";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "Nieprawidłowy format email";
    }
    
    if (empty($password)) {
        $errors[] = "Hasło jest wymagane";
    } elseif (strlen($password) < 8) {
        $errors[] = "Hasło musi mieć minimum 8 znaków";
    }
    
    if ($password !== $password_confirm) {
        $errors[] = "Hasła nie są identyczne";
    }
    
    // Jeśli brak błędów walidacji - sprawdzamy czy nick/email już istnieją
    if (empty($errors)) {
        // BEZPIECZNE - sprawdzamy czy username już istnieje
        $stmt = $db->prepare("SELECT id FROM players WHERE username = ?");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->store_result();
        
        if ($stmt->num_rows > 0) {
            $errors[] = "Nick '{$username}' jest już zajęty";
        }
        $stmt->close();
        
        // BEZPIECZNE - sprawdzamy czy email już istnieje
        $stmt = $db->prepare("SELECT id FROM players WHERE email = ?");
        $stmt->bind_param("s", $email);
        $stmt->execute();
        $stmt->store_result();
        
        if ($stmt->num_rows > 0) {
            $errors[] = "Email '{$email}' jest już zarejestrowany";
        }
        $stmt->close();
    }
    
    // Jeśli wszystko OK - dodajemy gracza do bazy
    if (empty($errors)) {
        // Hashowanie hasła (NIGDY nie zapisuj hasła w plain text!)
        $password_hash = password_hash($password, PASSWORD_DEFAULT);
        
        // BEZPIECZNE - INSERT z prepared statement
        $stmt = $db->prepare("INSERT INTO players (username, email, password, created_at) VALUES (?, ?, ?, NOW())");
        $stmt->bind_param("sss", $username, $email, $password_hash);
        
        if ($stmt->execute()) {
            // Pobieramy ID dodanego gracza
            $new_player_id = $stmt->insert_id;
            
            // Sprawdzamy ile wierszy zostało dodanych
            $affected_rows = $stmt->affected_rows;
            
            $success = true;
            $success_message = "Gracz '{$username}' został zarejestrowany! ID: {$new_player_id}";
            
            // Możemy od razu zalogować użytkownika
            // session_start();
            // $_SESSION['user_id'] = $new_player_id;
            // $_SESSION['username'] = $username;
            
        } else {
            $errors[] = "Wystąpił błąd podczas rejestracji: {$stmt->error}";
        }
        
        $stmt->close();
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja gracza</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            width: 100%;
            max-width: 500px;
        }
        h1 {
            margin-bottom: 30px;
            color: #333;
            text-align: center;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
            transition: border-color 0.3s;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
            text-align: center;
        }
        .info {
            background: #d1ecf1;
            color: #0c5460;
            padding: 10px;
            border-radius: 5px;
            margin-top: 15px;
            font-size: 12px;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Rejestracja Gracza</h1>
    
    <?php if ($success): ?>
        <div class="success">
            <?= htmlspecialchars($success_message) ?>
            <br><br>
            <a href="profil.php?id=<?= $new_player_id ?>">Zobacz profil</a> | 
            <a href="lista_graczy.php">Lista graczy</a>
        </div>
    <?php endif; ?>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <form method="POST">
        <div class="form-group">
            <label>Nick gracza:</label>
            <input type="text" name="username" 
                   value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" 
                   placeholder="np. ProGamer123" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Email:</label>
            <input type="email" name="email" 
                   value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" 
                   placeholder="twoj@email.com" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Hasło:</label>
            <input type="password" name="password" 
                   placeholder="Min. 8 znaków" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Powtórz hasło:</label>
            <input type="password" name="password_confirm" 
                   placeholder="Powtórz hasło" 
                   required>
        </div>
        
        <button type="submit">Zarejestruj się</button>
        
        <div class="info">
            <strong>Wymagania:</strong><br>
            • Nick: 3-50 znaków (litery, cyfry, podkreślnik)<br>
            • Hasło: minimum 8 znaków<br>
            • Poprawny format email
        </div>
    </form>
</div>

</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
        ]
    );
    
    $errors = [];
    $success = false;
    
    // PROFESJONALNE - sprawdzamy metodę HTTP
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        // Pobieramy i czyszczenie dane z formularza
        $username = trim($_POST['username'] ?? '');
        $email = trim($_POST['email'] ?? '');
        $password = $_POST['password'] ?? '';
        $password_confirm = $_POST['password_confirm'] ?? '';
        
        // Walidacja danych
        if (empty($username)) {
            $errors[] = "Nick jest wymagany";
        } elseif (strlen($username) < 3) {
            $errors[] = "Nick musi mieć minimum 3 znaki";
        } elseif (strlen($username) > 50) {
            $errors[] = "Nick może mieć maksymalnie 50 znaków";
        } elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
            $errors[] = "Nick może zawierać tylko litery, cyfry i podkreślnik";
        }
        
        if (empty($email)) {
            $errors[] = "Email jest wymagany";
        } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $errors[] = "Nieprawidłowy format email";
        }
        
        if (empty($password)) {
            $errors[] = "Hasło jest wymagane";
        } elseif (strlen($password) < 8) {
            $errors[] = "Hasło musi mieć minimum 8 znaków";
        }
        
        if ($password !== $password_confirm) {
            $errors[] = "Hasła nie są identyczne";
        }
        
        // Jeśli brak błędów walidacji - sprawdzamy czy nick/email już istnieją
        if (empty($errors)) {
            // BEZPIECZNE - sprawdzamy czy username już istnieje
            $stmt = $pdo->prepare("SELECT id FROM players WHERE username = :username");
            $stmt->execute(['username' => $username]);
            
            if ($stmt->fetch()) {
                $errors[] = "Nick '{$username}' jest już zajęty";
            }
            
            // BEZPIECZNE - sprawdzamy czy email już istnieje
            $stmt = $pdo->prepare("SELECT id FROM players WHERE email = :email");
            $stmt->execute(['email' => $email]);
            
            if ($stmt->fetch()) {
                $errors[] = "Email '{$email}' jest już zarejestrowany";
            }
        }
        
        // Jeśli wszystko OK - dodajemy gracza do bazy
        if (empty($errors)) {
            // Hashowanie hasła (NIGDY nie zapisuj hasła w plain text!)
            $password_hash = password_hash($password, PASSWORD_DEFAULT);
            
            // BEZPIECZNE - INSERT z prepared statement
            $stmt = $pdo->prepare("INSERT INTO players (username, email, password, created_at) VALUES (:username, :email, :password, NOW())");
            
            if ($stmt->execute([
                'username' => $username,
                'email' => $email,
                'password' => $password_hash
            ])) {
                // Pobieramy ID dodanego gracza
                $new_player_id = $pdo->lastInsertId();
                
                // Sprawdzamy ile wierszy zostało dodanych
                $affected_rows = $stmt->rowCount();
                
                $success = true;
                $success_message = "Gracz '{$username}' został zarejestrowany! ID: {$new_player_id}";
                
                // Możemy od razu zalogować użytkownika
                // session_start();
                // $_SESSION['user_id'] = $new_player_id;
                // $_SESSION['username'] = $username;
            }
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    $errors[] = "Wystąpił błąd podczas rejestracji. Spróbuj ponownie.";
}

$pdo = null;
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja gracza</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            width: 100%;
            max-width: 500px;
        }
        h1 {
            margin-bottom: 30px;
            color: #333;
            text-align: center;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
            transition: border-color 0.3s;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
            text-align: center;
        }
        .info {
            background: #d1ecf1;
            color: #0c5460;
            padding: 10px;
            border-radius: 5px;
            margin-top: 15px;
            font-size: 12px;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Rejestracja Gracza</h1>
    
    <?php if ($success): ?>
        <div class="success">
            <?= htmlspecialchars($success_message) ?>
            <br><br>
            <a href="profil.php?id=<?= $new_player_id ?>">Zobacz profil</a> | 
            <a href="lista_graczy.php">Lista graczy</a>
        </div>
    <?php endif; ?>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <form method="POST">
        <div class="form-group">
            <label>Nick gracza:</label>
            <input type="text" name="username" 
                   value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" 
                   placeholder="np. ProGamer123" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Email:</label>
            <input type="email" name="email" 
                   value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" 
                   placeholder="twoj@email.com" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Hasło:</label>
            <input type="password" name="password" 
                   placeholder="Min. 8 znaków" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Powtórz hasło:</label>
            <input type="password" name="password_confirm" 
                   placeholder="Powtórz hasło" 
                   required>
        </div>
        
        <button type="submit">Zarejestruj się</button>
        
        <div class="info">
            <strong>Wymagania:</strong><br>
            • Nick: 3-50 znaków (litery, cyfry, podkreślnik)<br>
            • Hasło: minimum 8 znaków<br>
            • Poprawny format email
        </div>
    </form>
</div>

</body>
</html>

Sprawdzanie wyniku operacji INSERT

Metody sprawdzania dla MySQLi:
// 1. Pobierz ID ostatnio dodanego rekordu
$new_id = $stmt->insert_id;  // Obiektowo
$new_id = mysqli_insert_id($db);  // Proceduralnie

// 2. Sprawdź ile wierszy zostało dodanych
$affected_rows = $stmt->affected_rows;  // Obiektowo
$affected_rows = mysqli_affected_rows($db);  // Proceduralnie

// 3. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    echo "Sukces!";
} else {
    echo "Błąd: {$stmt->error}";
}
Metody sprawdzania dla PDO:
// 1. Pobierz ID ostatnio dodanego rekordu
$new_id = $pdo->lastInsertId();

// 2. Sprawdź ile wierszy zostało dodanych
$affected_rows = $stmt->rowCount();

// 3. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    echo "Sukces!";
} else {
    echo "Błąd!";
}

// 4. Obsługa wyjątków (try-catch)
try {
    $stmt->execute();
    echo "Sukces!";
} catch (PDOException $e) {
    echo "Błąd: {$e->getMessage()}";
}

Przykład 2: Dodawanie wielu graczy naraz (batch INSERT)

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');

// Tablica graczy do dodania
$gracze = [
    ['username' => 'TestPlayer1', 'email' => 'test1@example.com', 'level' => 5],
    ['username' => 'TestPlayer2', 'email' => 'test2@example.com', 'level' => 10],
    ['username' => 'TestPlayer3', 'email' => 'test3@example.com', 'level' => 15]
];

// BEZPIECZNE - przygotowanie zapytania raz
$stmt = $db->prepare("INSERT INTO players (username, email, level, created_at) VALUES (?, ?, ?, NOW())");

$dodano = 0;
$bledy = [];

foreach ($gracze as $gracz) {
    // Bindowanie parametrów
    $stmt->bind_param("ssi", $gracz['username'], $gracz['email'], $gracz['level']);
    
    if ($stmt->execute()) {
        $dodano++;
        
        // Możesz pobrać ID każdego dodanego gracza
        $last_id = $stmt->insert_id;
        echo "Dodano: {$gracz['username']} (ID: {$last_id})<br>";
    } else {
        $bledy[] = "Błąd dla {$gracz['username']}: {$stmt->error}";
    }
}

echo "<br><strong>Podsumowanie:</strong><br>";
echo "Dodano: {$dodano} graczy<br>";
echo "Błędów: " . count($bledy) . "<br>";

if (!empty($bledy)) {
    echo "<br><strong>Szczegóły błędów:</strong><br>";
    foreach ($bledy as $blad) {
        echo "{$blad}<br>";
    }
}

$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
        ]
    );
    
    // Tablica graczy do dodania
    $gracze = [
        ['username' => 'TestPlayer1', 'email' => 'test1@example.com', 'level' => 5],
        ['username' => 'TestPlayer2', 'email' => 'test2@example.com', 'level' => 10],
        ['username' => 'TestPlayer3', 'email' => 'test3@example.com', 'level' => 15]
    ];
    
    // BEZPIECZNE - przygotowanie zapytania raz
    $stmt = $pdo->prepare("INSERT INTO players (username, email, level, created_at) VALUES (:username, :email, :level, NOW())");
    
    $dodano = 0;
    $bledy = [];
    
    foreach ($gracze as $gracz) {
        try {
            $stmt->execute($gracz);
            $dodano++;
            
            // Możesz pobrać ID każdego dodanego gracza
            $last_id = $pdo->lastInsertId();
            echo "Dodano: {$gracz['username']} (ID: {$last_id})<br>";
            
        } catch (PDOException $e) {
            $bledy[] = "Błąd dla {$gracz['username']}: {$e->getMessage()}";
        }
    }
    
    echo "<br><strong>Podsumowanie:</strong><br>";
    echo "Dodano: {$dodano} graczy<br>";
    echo "Błędów: " . count($bledy) . "<br>";
    
    if (!empty($bledy)) {
        echo "<br><strong>Szczegóły błędów:</strong><br>";
        foreach ($bledy as $blad) {
            echo "{$blad}<br>";
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

UPDATE – Aktualizacja istniejących rekordów

Przykład 3: Edycja profilu gracza (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');

$errors = [];
$success = false;
$gracz = null;

// Pobieramy ID gracza z URL
$player_id = (int)($_GET['id'] ?? 0);

if ($player_id <= 0) {
    die('Nieprawidłowe ID gracza');
}

// Pobieramy dane gracza do wyświetlenia w formularzu
$stmt = $db->prepare("SELECT * FROM players WHERE id = ?");
$stmt->bind_param("i", $player_id);
$stmt->execute();
$wynik = $stmt->get_result();
$gracz = $wynik->fetch_assoc();
$stmt->close();

if (!$gracz) {
    die('Nie znaleziono gracza o ID: ' . $player_id);
}

// PROFESJONALNE - sprawdzamy metodę HTTP
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Pobieramy dane z formularza
    $email = trim($_POST['email'] ?? '');
    $level = (int)($_POST['level'] ?? 1);
    $coins = (int)($_POST['coins'] ?? 0);
    $rank_name = $_POST['rank_name'] ?? 'Bronze';
    
    // Walidacja danych
    if (empty($email)) {
        $errors[] = "Email jest wymagany";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "Nieprawidłowy format email";
    }
    
    if ($level < 1 || $level > 100) {
        $errors[] = "Poziom musi być między 1 a 100";
    }
    
    if ($coins < 0) {
        $errors[] = "Coins nie może być ujemny";
    }
    
    $allowed_ranks = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Master', 'Grandmaster'];
    if (!in_array($rank_name, $allowed_ranks)) {
        $errors[] = "Nieprawidłowa ranga";
    }
    
    // Sprawdzamy czy email nie jest już używany przez innego gracza
    if (empty($errors)) {
        $stmt = $db->prepare("SELECT id FROM players WHERE email = ? AND id != ?");
        $stmt->bind_param("si", $email, $player_id);
        $stmt->execute();
        $stmt->store_result();
        
        if ($stmt->num_rows > 0) {
            $errors[] = "Email '{$email}' jest już używany przez innego gracza";
        }
        $stmt->close();
    }
    
    // Jeśli brak błędów - aktualizujemy dane
    if (empty($errors)) {
        // BEZPIECZNE - UPDATE z prepared statement
        $stmt = $db->prepare("UPDATE players SET email = ?, level = ?, coins = ?, rank_name = ?, updated_at = NOW() WHERE id = ?");
        $stmt->bind_param("siisi", $email, $level, $coins, $rank_name, $player_id);
        
        if ($stmt->execute()) {
            // Sprawdzamy ile wierszy zostało zaktualizowanych
            $affected_rows = $stmt->affected_rows;
            
            if ($affected_rows > 0) {
                $success = true;
                $success_message = "Profil gracza '{$gracz['username']}' został zaktualizowany! (Zmieniono {$affected_rows} rekord)";
                
                // Odświeżamy dane gracza
                $stmt_refresh = $db->prepare("SELECT * FROM players WHERE id = ?");
                $stmt_refresh->bind_param("i", $player_id);
                $stmt_refresh->execute();
                $wynik_refresh = $stmt_refresh->get_result();
                $gracz = $wynik_refresh->fetch_assoc();
                $stmt_refresh->close();
            } else {
                $success_message = "Nie wprowadzono żadnych zmian (dane są identyczne)";
            }
        } else {
            $errors[] = "Wystąpił błąd podczas aktualizacji: {$stmt->error}";
        }
        
        $stmt->close();
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Edycja profilu - <?= htmlspecialchars($gracz['username']) ?></title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
        }
        h1 {
            margin-bottom: 10px;
            color: #333;
        }
        .subtitle {
            color: #666;
            margin-bottom: 30px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input, select {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        input:focus, select:focus {
            outline: none;
            border-color: #667eea;
        }
        .form-row {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 20px;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
        }
        .info-box {
            background: #e7f3ff;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #2196F3;
        }
        .back-link {
            display: inline-block;
            margin-top: 20px;
            color: #667eea;
            text-decoration: none;
        }
        .back-link:hover {
            text-decoration: underline;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Edycja profilu gracza</h1>
    <div class="subtitle">ID: <?= $gracz['id'] ?> | Nick: <strong><?= htmlspecialchars($gracz['username']) ?></strong></div>
    
    <?php if ($success): ?>
        <div class="success">
            <?= htmlspecialchars($success_message) ?>
        </div>
    <?php endif; ?>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <div class="info-box">
        <strong>Informacje:</strong><br>
        • Nick gracza nie może być edytowany (to unikalna nazwa)<br>
        • Hasło można zmienić w osobnej sekcji zabezpieczeń<br>
        • Data rejestracji: <?= date('d.m.Y H:i', strtotime($gracz['created_at'])) ?>
    </div>
    
    <form method="POST">
        <div class="form-group">
            <label>Email:</label>
            <input type="email" name="email" 
                   value="<?= htmlspecialchars($gracz['email']) ?>" 
                   required>
        </div>
        
        <div class="form-row">
            <div class="form-group">
                <label>Poziom (1-100):</label>
                <input type="number" name="level" 
                       min="1" max="100"
                       value="<?= $gracz['level'] ?>" 
                       required>
            </div>
            
            <div class="form-group">
                <label>Coins:</label>
                <input type="number" name="coins" 
                       min="0"
                       value="<?= $gracz['coins'] ?>" 
                       required>
            </div>
        </div>
        
        <div class="form-group">
            <label>Ranga:</label>
            <select name="rank_name" required>
                <option value="Bronze" <?= $gracz['rank_name'] == 'Bronze' ? 'selected' : '' ?>>Bronze</option>
                <option value="Silver" <?= $gracz['rank_name'] == 'Silver' ? 'selected' : '' ?>>Silver</option>
                <option value="Gold" <?= $gracz['rank_name'] == 'Gold' ? 'selected' : '' ?>>Gold</option>
                <option value="Platinum" <?= $gracz['rank_name'] == 'Platinum' ? 'selected' : '' ?>>Platinum</option>
                <option value="Diamond" <?= $gracz['rank_name'] == 'Diamond' ? 'selected' : '' ?>>Diamond</option>
                <option value="Master" <?= $gracz['rank_name'] == 'Master' ? 'selected' : '' ?>>Master</option>
                <option value="Grandmaster" <?= $gracz['rank_name'] == 'Grandmaster' ? 'selected' : '' ?>>Grandmaster</option>
            </select>
        </div>
        
        <button type="submit">Zapisz zmiany</button>
    </form>
    
    <a href="profil.php?id=<?= $player_id ?>" class="back-link">← Powrót do profilu</a>
</div>

</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
        ]
    );
    
    $errors = [];
    $success = false;
    $gracz = null;
    
    // Pobieramy ID gracza z URL
    $player_id = (int)($_GET['id'] ?? 0);
    
    if ($player_id <= 0) {
        die('Nieprawidłowe ID gracza');
    }
    
    // Pobieramy dane gracza do wyświetlenia w formularzu
    $stmt = $pdo->prepare("SELECT * FROM players WHERE id = :id");
    $stmt->execute(['id' => $player_id]);
    $gracz = $stmt->fetch();
    
    if (!$gracz) {
        die('Nie znaleziono gracza o ID: ' . $player_id);
    }
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        // Pobieramy dane z formularza
        $email = trim($_POST['email'] ?? '');
        $level = (int)($_POST['level'] ?? 1);
        $coins = (int)($_POST['coins'] ?? 0);
        $rank_name = $_POST['rank_name'] ?? 'Bronze';
        
        // Walidacja danych
        if (empty($email)) {
            $errors[] = "Email jest wymagany";
        } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $errors[] = "Nieprawidłowy format email";
        }
        
        if ($level < 1 || $level > 100) {
            $errors[] = "Poziom musi być między 1 a 100";
        }
        
        if ($coins < 0) {
            $errors[] = "Coins nie może być ujemny";
        }
        
        $allowed_ranks = ['Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Master', 'Grandmaster'];
        if (!in_array($rank_name, $allowed_ranks)) {
            $errors[] = "Nieprawidłowa ranga";
        }
        
        // Sprawdzamy czy email nie jest już używany przez innego gracza
        if (empty($errors)) {
            $stmt = $pdo->prepare("SELECT id FROM players WHERE email = :email AND id != :id");
            $stmt->execute(['email' => $email, 'id' => $player_id]);
            
            if ($stmt->fetch()) {
                $errors[] = "Email '{$email}' jest już używany przez innego gracza";
            }
        }
        
        // Jeśli brak błędów - aktualizujemy dane
        if (empty($errors)) {
            // BEZPIECZNE - UPDATE z prepared statement
            $stmt = $pdo->prepare("UPDATE players SET email = :email, level = :level, coins = :coins, rank_name = :rank_name, updated_at = NOW() WHERE id = :id");
            
            if ($stmt->execute([
                'email' => $email,
                'level' => $level,
                'coins' => $coins,
                'rank_name' => $rank_name,
                'id' => $player_id
            ])) {
                // Sprawdzamy ile wierszy zostało zaktualizowanych
                $affected_rows = $stmt->rowCount();
                
                if ($affected_rows > 0) {
                    $success = true;
                    $success_message = "Profil gracza '{$gracz['username']}' został zaktualizowany! (Zmieniono {$affected_rows} rekord)";
                    
                    // Odświeżamy dane gracza
                    $stmt_refresh = $pdo->prepare("SELECT * FROM players WHERE id = :id");
                    $stmt_refresh->execute(['id' => $player_id]);
                    $gracz = $stmt_refresh->fetch();
                } else {
                    $success_message = "Nie wprowadzono żadnych zmian (dane są identyczne)";
                }
            }
        }
    }
    
} 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>Edycja profilu - <?= htmlspecialchars($gracz['username']) ?></title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
        }
        h1 {
            margin-bottom: 10px;
            color: #333;
        }
        .subtitle {
            color: #666;
            margin-bottom: 30px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input, select {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        input:focus, select:focus {
            outline: none;
            border-color: #667eea;
        }
        .form-row {
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 20px;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
        }
        .info-box {
            background: #e7f3ff;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #2196F3;
        }
        .back-link {
            display: inline-block;
            margin-top: 20px;
            color: #667eea;
            text-decoration: none;
        }
        .back-link:hover {
            text-decoration: underline;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Edycja profilu gracza</h1>
    <div class="subtitle">ID: <?= $gracz['id'] ?> | Nick: <strong><?= htmlspecialchars($gracz['username']) ?></strong></div>
    
    <?php if ($success): ?>
        <div class="success">
            <?= htmlspecialchars($success_message) ?>
        </div>
    <?php endif; ?>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <div class="info-box">
        <strong>Informacje:</strong><br>
        • Nick gracza nie może być edytowany (to unikalna nazwa)<br>
        • Hasło można zmienić w osobnej sekcji zabezpieczeń<br>
        • Data rejestracji: <?= date('d.m.Y H:i', strtotime($gracz['created_at'])) ?>
    </div>
    
    <form method="POST">
        <div class="form-group">
            <label>Email:</label>
            <input type="email" name="email" 
                   value="<?= htmlspecialchars($gracz['email']) ?>" 
                   required>
        </div>
        
        <div class="form-row">
            <div class="form-group">
                <label>Poziom (1-100):</label>
                <input type="number" name="level" 
                       min="1" max="100"
                       value="<?= $gracz['level'] ?>" 
                       required>
            </div>
            
            <div class="form-group">
                <label>Coins:</label>
                <input type="number" name="coins" 
                       min="0"
                       value="<?= $gracz['coins'] ?>" 
                       required>
            </div>
        </div>
        
        <div class="form-group">
            <label>Ranga:</label>
            <select name="rank_name" required>
                <option value="Bronze" <?= $gracz['rank_name'] == 'Bronze' ? 'selected' : '' ?>>Bronze</option>
                <option value="Silver" <?= $gracz['rank_name'] == 'Silver' ? 'selected' : '' ?>>Silver</option>
                <option value="Gold" <?= $gracz['rank_name'] == 'Gold' ? 'selected' : '' ?>>Gold</option>
                <option value="Platinum" <?= $gracz['rank_name'] == 'Platinum' ? 'selected' : '' ?>>Platinum</option>
                <option value="Diamond" <?= $gracz['rank_name'] == 'Diamond' ? 'selected' : '' ?>>Diamond</option>
                <option value="Master" <?= $gracz['rank_name'] == 'Master' ? 'selected' : '' ?>>Master</option>
                <option value="Grandmaster" <?= $gracz['rank_name'] == 'Grandmaster' ? 'selected' : '' ?>>Grandmaster</option>
            </select>
        </div>
        
        <button type="submit">Zapisz zmiany</button>
    </form>
    
    <a href="profil.php?id=<?= $player_id ?>" class="back-link">← Powrót do profilu</a>
</div>

</body>
</html>

Sprawdzanie wyniku operacji UPDATE

Metody sprawdzania dla MySQLi:
// 1. Sprawdź ile wierszy zostało zaktualizowanych
$affected_rows = $stmt->affected_rows;  // Obiektowo

// 2. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    if ($affected_rows > 0) {
        echo "Zaktualizowano {$affected_rows} rekord(ów)";
    } else {
        echo "Nie wprowadzono zmian (dane są identyczne)";
    }
} else {
    echo "Błąd: {$stmt->error}";
}

// 3. Sprawdź czy rekord istnieje przed UPDATE
$stmt_check = $db->prepare("SELECT id FROM players WHERE id = ?");
$stmt_check->bind_param("i", $player_id);
$stmt_check->execute();
$stmt_check->store_result();

if ($stmt_check->num_rows == 0) {
    echo "Rekord o tym ID nie istnieje";
}
Metody sprawdzania dla PDO:
// 1. Sprawdź ile wierszy zostało zaktualizowanych
$affected_rows = $stmt->rowCount();

// 2. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    if ($affected_rows > 0) {
        echo "Zaktualizowano {$affected_rows} rekord(ów)";
    } else {
        echo "Nie wprowadzono zmian (dane są identyczne)";
    }
}

// 3. Sprawdź czy rekord istnieje przed UPDATE
$stmt_check = $pdo->prepare("SELECT id FROM players WHERE id = :id");
$stmt_check->execute(['id' => $player_id]);

if (!$stmt_check->fetch()) {
    echo "Rekord o tym ID nie istnieje";
}

Przykład 4: Aktualizacja wielu graczy naraz (batch UPDATE)

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');

// Przykład: Dodaj 100 coins wszystkim graczom Bronze
$rank = 'Bronze';
$bonus_coins = 100;

$stmt = $db->prepare("UPDATE players SET coins = coins + ? WHERE rank_name = ?");
$stmt->bind_param("is", $bonus_coins, $rank);

if ($stmt->execute()) {
    $affected_rows = $stmt->affected_rows;
    
    if ($affected_rows > 0) {
        echo "Sukces! Dodano {$bonus_coins} coins dla {$affected_rows} graczy rangi {$rank}";
    } else {
        echo "Nie znaleziono graczy rangi {$rank} lub już mieli te coins";
    }
} else {
    echo "Błąd: {$stmt->error}";
}

$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
        ]
    );
    
    // Przykład: Dodaj 100 coins wszystkim graczom Bronze
    $rank = 'Bronze';
    $bonus_coins = 100;
    
    $stmt = $pdo->prepare("UPDATE players SET coins = coins + :bonus WHERE rank_name = :rank");
    
    if ($stmt->execute(['bonus' => $bonus_coins, 'rank' => $rank])) {
        $affected_rows = $stmt->rowCount();
        
        if ($affected_rows > 0) {
            echo "Sukces! Dodano {$bonus_coins} coins dla {$affected_rows} graczy rangi {$rank}";
        } else {
            echo "Nie znaleziono graczy rangi {$rank} lub już mieli te coins";
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    die('Błąd bazy danych');
}

$pdo = null;
?>

DELETE – Usuwanie rekordów

Przykład 5: Usuwanie gracza z potwierdzeniem (GET + 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');

$gracz = null;
$deleted = false;

// Pobieramy ID gracza z URL
$player_id = (int)($_GET['id'] ?? 0);

if ($player_id <= 0) {
    die('Nieprawidłowe ID gracza');
}

// Pobieramy dane gracza do wyświetlenia
$stmt = $db->prepare("SELECT * FROM players WHERE id = ?");
$stmt->bind_param("i", $player_id);
$stmt->execute();
$wynik = $stmt->get_result();
$gracz = $wynik->fetch_assoc();
$stmt->close();

if (!$gracz) {
    die('Nie znaleziono gracza o ID: ' . $player_id);
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_delete'])) {
    
    // Dodatkowe zabezpieczenie - sprawdzamy token CSRF (opcjonalnie)
    // if ($_POST['token'] !== $_SESSION['token']) die('Invalid token');

    $stmt = $db->prepare("DELETE FROM players WHERE id = ?");
    $stmt->bind_param("i", $player_id);
    
    if ($stmt->execute()) {
        // Sprawdzamy ile wierszy zostało usuniętych
        $affected_rows = $stmt->affected_rows;
        
        if ($affected_rows > 0) {
            $deleted = true;
            $username = htmlspecialchars($gracz['username']);
            $success_message = "Gracz '{$username}' (ID: {$player_id}) został permanentnie usunięty z bazy danych.";
        } else {
            $error_message = "Rekord nie został usunięty (możliwe że już nie istnieje)";
        }
    } else {
        $error_message = "Błąd podczas usuwania: {$stmt->error}";
    }
    
    $stmt->close();
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Usuń gracza - <?= htmlspecialchars($gracz['username'] ?? 'Usunięty') ?></title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.3);
            max-width: 600px;
            width: 100%;
        }
        h1 {
            color: #d32f2f;
            margin-bottom: 20px;
            text-align: center;
        }
        .warning {
            background: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 20px;
            margin-bottom: 20px;
            border-radius: 5px;
        }
        .player-info {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .player-info h3 {
            margin-bottom: 15px;
            color: #333;
        }
        .player-info p {
            margin: 8px 0;
            color: #666;
        }
        .buttons {
            display: flex;
            gap: 10px;
            margin-top: 30px;
        }
        button, .button {
            flex: 1;
            padding: 15px;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            text-align: center;
            text-decoration: none;
            display: inline-block;
        }
        .btn-delete {
            background: #d32f2f;
            color: white;
        }
        .btn-delete:hover {
            background: #b71c1c;
        }
        .btn-cancel {
            background: #6c757d;
            color: white;
        }
        .btn-cancel:hover {
            background: #5a6268;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
            text-align: center;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .checkbox-confirm {
            margin: 20px 0;
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .checkbox-confirm input {
            width: 20px;
            height: 20px;
            cursor: pointer;
        }
        .checkbox-confirm label {
            cursor: pointer;
            user-select: none;
        }
    </style>
</head>
<body>

<div class="container">
    <?php if ($deleted): ?>
        <div class="success">
            <h2>Gracz został usunięty</h2>
            <p><?= $success_message ?></p>
            <br>
            <a href="lista_graczy.php" class="button btn-cancel">← Powrót do listy graczy</a>
        </div>
    <?php else: ?>
        <h1>Usuń gracza</h1>
        
        <?php if (isset($error_message)): ?>
            <div class="error">
                <?= htmlspecialchars($error_message) ?>
            </div>
        <?php endif; ?>
        
        <div class="warning">
            <strong>OSTRZEŻENIE:</strong><br>
            Ta operacja jest <strong>nieodwracalna</strong>! Gracz i wszystkie jego dane zostaną <strong>permanentnie usunięte</strong> z bazy danych.
        </div>
        
        <div class="player-info">
            <h3>Dane gracza do usunięcia:</h3>
            <p><strong>ID:</strong> <?= $gracz['id'] ?></p>
            <p><strong>Username:</strong> <?= htmlspecialchars($gracz['username']) ?></p>
            <p><strong>Email:</strong> <?= htmlspecialchars($gracz['email']) ?></p>
            <p><strong>Level:</strong> <?= $gracz['level'] ?></p>
            <p><strong>Rank:</strong> <?= $gracz['rank_name'] ?></p>
            <p><strong>Coins:</strong> <?= number_format($gracz['coins']) ?></p>
            <p><strong>Rozegrane gry:</strong> <?= $gracz['games_played'] ?></p>
            <p><strong>Data rejestracji:</strong> <?= date('d.m.Y H:i', strtotime($gracz['created_at'])) ?></p>
        </div>
        
        <form method="POST">
            <div class="checkbox-confirm">
                <input type="checkbox" id="confirm" name="confirm_check" required>
                <label for="confirm">Potwierdzam, że chcę <strong>permanentnie usunąć</strong> tego gracza</label>
            </div>
            
            <div class="buttons">
                <a href="profil.php?id=<?= $player_id ?>" class="button btn-cancel">Anuluj</a>
                <button type="submit" name="confirm_delete" class="btn-delete" onclick="return confirm('Czy NA PEWNO chcesz usunąć tego gracza? Ta operacja jest NIEODWRACALNA!')">
                    Usuń gracza
                </button>
            </div>
        </form>
    <?php endif; ?>
</div>

</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
        ]
    );
    
    $gracz = null;
    $deleted = false;
    
    // Pobieramy ID gracza z URL
    $player_id = (int)($_GET['id'] ?? 0);
    
    if ($player_id <= 0) {
        die('Nieprawidłowe ID gracza');
    }
    
    // Pobieramy dane gracza do wyświetlenia
    $stmt = $pdo->prepare("SELECT * FROM players WHERE id = :id");
    $stmt->execute(['id' => $player_id]);
    $gracz = $stmt->fetch();
    
    if (!$gracz) {
        die('Nie znaleziono gracza o ID: ' . $player_id);
    }
    
    if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_delete'])) {
        
        // Dodatkowe zabezpieczenie - sprawdzamy token CSRF (opcjonalnie)
        // if ($_POST['token'] !== $_SESSION['token']) die('Invalid token');
        
        // BEZPIECZNE - DELETE z prepared statement
        $stmt = $pdo->prepare("DELETE FROM players WHERE id = :id");
        
        if ($stmt->execute(['id' => $player_id])) {
            // Sprawdzamy ile wierszy zostało usuniętych
            $affected_rows = $stmt->rowCount();
            
            if ($affected_rows > 0) {
                $deleted = true;
                $username = htmlspecialchars($gracz['username']);
                $success_message = "Gracz '{$username}' (ID: {$player_id}) został permanentnie usunięty z bazy danych.";
            } else {
                $error_message = "Rekord nie został usunięty (możliwe że już nie istnieje)";
            }
        }
    }
    
} catch (PDOException $e) {
    error_log("DB Error: {$e->getMessage()}");
    $error_message = "Wystąpił błąd podczas usuwania gracza";
}

$pdo = null;
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Usuń gracza - <?= htmlspecialchars($gracz['username'] ?? 'Usunięty') ?></title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.3);
            max-width: 600px;
            width: 100%;
        }
        h1 {
            color: #d32f2f;
            margin-bottom: 20px;
            text-align: center;
        }
        .warning {
            background: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 20px;
            margin-bottom: 20px;
            border-radius: 5px;
        }
        .player-info {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .player-info h3 {
            margin-bottom: 15px;
            color: #333;
        }
        .player-info p {
            margin: 8px 0;
            color: #666;
        }
        .buttons {
            display: flex;
            gap: 10px;
            margin-top: 30px;
        }
        button, .button {
            flex: 1;
            padding: 15px;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            text-align: center;
            text-decoration: none;
            display: inline-block;
        }
        .btn-delete {
            background: #d32f2f;
            color: white;
        }
        .btn-delete:hover {
            background: #b71c1c;
        }
        .btn-cancel {
            background: #6c757d;
            color: white;
        }
        .btn-cancel:hover {
            background: #5a6268;
        }
        .success {
            background: #d4edda;
            color: #155724;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
            text-align: center;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .checkbox-confirm {
            margin: 20px 0;
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .checkbox-confirm input {
            width: 20px;
            height: 20px;
            cursor: pointer;
        }
        .checkbox-confirm label {
            cursor: pointer;
            user-select: none;
        }
    </style>
</head>
<body>

<div class="container">
    <?php if ($deleted): ?>
        <div class="success">
            <h2>Gracz został usunięty</h2>
            <p><?= $success_message ?></p>
            <br>
            <a href="lista_graczy.php" class="button btn-cancel">← Powrót do listy graczy</a>
        </div>
    <?php else: ?>
        <h1>Usuń gracza</h1>
        
        <?php if (isset($error_message)): ?>
            <div class="error">
                <?= htmlspecialchars($error_message) ?>
            </div>
        <?php endif; ?>
        
        <div class="warning">
            <strong>OSTRZEŻENIE:</strong><br>
            Ta operacja jest <strong>nieodwracalna</strong>! Gracz i wszystkie jego dane zostaną <strong>permanentnie usunięte</strong> z bazy danych.
        </div>
        
        <div class="player-info">
            <h3>Dane gracza do usunięcia:</h3>
            <p><strong>ID:</strong> <?= $gracz['id'] ?></p>
            <p><strong>Username:</strong> <?= htmlspecialchars($gracz['username']) ?></p>
            <p><strong>Email:</strong> <?= htmlspecialchars($gracz['email']) ?></p>
            <p><strong>Level:</strong> <?= $gracz['level'] ?></p>
            <p><strong>Rank:</strong> <?= $gracz['rank_name'] ?></p>
            <p><strong>Coins:</strong> <?= number_format($gracz['coins']) ?></p>
            <p><strong>Rozegrane gry:</strong> <?= $gracz['games_played'] ?></p>
            <p><strong>Data rejestracji:</strong> <?= date('d.m.Y H:i', strtotime($gracz['created_at'])) ?></p>
        </div>
        
        <form method="POST">
            <div class="checkbox-confirm">
                <input type="checkbox" id="confirm" name="confirm_check" required>
                <label for="confirm">Potwierdzam, że chcę <strong>permanentnie usunąć</strong> tego gracza</label>
            </div>
            
            <div class="buttons">
                <a href="profil.php?id=<?= $player_id ?>" class="button btn-cancel">Anuluj</a>
                <button type="submit" name="confirm_delete" class="btn-delete" onclick="return confirm('Czy NA PEWNO chcesz usunąć tego gracza? Ta operacja jest NIEODWRACALNA!')">
                    Usuń gracza
                </button>
            </div>
        </form>
    <?php endif; ?>
</div>

</body>
</html>

Sprawdzanie wyniku operacji DELETE

Metody sprawdzania dla MySQLi:
// 1. Sprawdź ile wierszy zostało usuniętych
$affected_rows = $stmt->affected_rows;

// 2. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    if ($affected_rows > 0) {
        echo "Usunięto {$affected_rows} rekord(ów)";
    } else {
        echo "Nie usunięto żadnego rekordu (możliwe że nie istniał)";
    }
} else {
    echo "Błąd: {$stmt->error}";
}
Metody sprawdzania dla PDO:
// 1. Sprawdź ile wierszy zostało usuniętych
$affected_rows = $stmt->rowCount();

// 2. Sprawdź czy zapytanie się wykonało
if ($stmt->execute()) {
    if ($affected_rows > 0) {
        echo "Usunięto {$affected_rows} rekord(ów)";
    } else {
        echo "Nie usunięto żadnego rekordu (możliwe że nie istniał)";
    }
}

PHP – Zabezpieczenie przed duplikatami po odświeżeniu strony (F5)

Problem: Ponowne wysłanie formularza

Co się dzieje gdy użytkownik naciśnie F5?

1. Użytkownik wypełnia formularz rejestracji
2. Klika "Wyślij" (POST)
3. Dane zostają zapisane w bazie
4. Strona wyświetla "Sukces!"
5. Użytkownik naciska F5 (odświeżenie)
6. Przeglądarka pyta: "Czy ponownie wysłać formularz?"
7. Jeśli użytkownik kliknie "Tak" → próba dodania tego samego gracza ponownie!

Konsekwencje:

  • Duplikaty w bazie danych
  • Błędy (jeśli są ograniczenia UNIQUE)
  • Złe doświadczenie użytkownika
  • Problemy z logowaniem transakcji

Rozwiązanie: Post/Redirect/Get (PRG Pattern)

Po wykonaniu operacji należy dodać kod

// PRZEKIEROWANIE - zapobiega duplikatom
        header("Location: nazwa_strony.php?<dalej możemy przekazać dane w GET>;
        exit; // ZAWSZE exit po header!

Przykład 1: Podstawowe przekierowanie

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

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    
    // Walidacja...
    
    $stmt = $db->prepare("INSERT INTO players (username, email, created_at) VALUES (?, ?, NOW())");
    $stmt->bind_param("ss", $username, $email);
    
    if ($stmt->execute()) {
        $new_player_id = $stmt->insert_id;
        
        $stmt->close();
        $db->close();
        
        // PRZEKIEROWANIE - zapobiega duplikatom
        header("Location: profil.php?id={$new_player_id}");
        exit; // ZAWSZE exit po header!
    }
    
    $stmt->close();
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja</title>
</head>
<body>

<form method="POST">
    <input type="text" name="username" required>
    <input type="email" name="email" required>
    <button type="submit">Zarejestruj</button>
</form>

</body>
</html>

Przykład 2: Przekazywanie informacji przez URL (GET)

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

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $level = (int)($_POST['level'] ?? 1);
    
    // Walidacja...
    
    $stmt = $db->prepare("INSERT INTO players (username, email, level, created_at) VALUES (?, ?, ?, NOW())");
    $stmt->bind_param("ssi", $username, $email, $level);
    
    if ($stmt->execute()) {
        $new_player_id = $stmt->insert_id;
        $affected_rows = $stmt->affected_rows;
        
        $stmt->close();
        $db->close();
        
        // Przekazanie wielu informacji przez URL
        header("Location: sukces.php?id={$new_player_id}&username=" . urlencode($username) . "&affected={$affected_rows}&action=register");
        exit;
    }
    
    $stmt->close();
}

$db->close();
?>

Plik: sukces.php

<?php
// Odbieramy dane z URL
$new_player_id = (int)($_GET['id'] ?? 0);
$username = htmlspecialchars($_GET['username'] ?? 'Nieznany');
$affected_rows = (int)($_GET['affected'] ?? 0);
$action = htmlspecialchars($_GET['action'] ?? '');

if ($new_player_id <= 0) {
    die('Nieprawidłowe ID');
}
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja zakończona</title>
    <style>
        .success-box {
            background: #d4edda;
            color: #155724;
            padding: 20px;
            border-radius: 5px;
            border-left: 4px solid #c3e6cb;
            margin: 20px;
        }
        .info {
            background: #d1ecf1;
            color: #0c5460;
            padding: 15px;
            border-radius: 5px;
            margin: 20px;
        }
    </style>
</head>
<body>

<div class="success-box">
    <h2>Rejestracja zakończona sukcesem!</h2>
    <p>Witaj, <strong><?= $username ?></strong>!</p>
</div>

<div class="info">
    <strong>Szczegóły operacji:</strong><br>
    ID nowego gracza: <strong><?= $new_player_id ?></strong><br>
    Liczba dodanych rekordów: <strong><?= $affected_rows ?></strong><br>
    Typ operacji: <strong><?= $action ?></strong><br>
    <br>
    <a href="profil.php?id=<?= $new_player_id ?>">Zobacz profil</a> |
    <a href="lista_graczy.php">Lista graczy</a>
</div>

</body>
</html>

Zalety:

  • Proste
  • Nie wymaga sesji
  • Działa zawsze

Wady:

  • Dane widoczne w URL
  • Ograniczona ilość danych (max ~2000 znaków)
  • Nie nadaje się dla wrażliwych informacji

Przykład 3: Przekazywanie informacji przez SESJĘ

Kiedy używać:

  • Komunikaty sukcesu/błędu
  • Więcej danych
  • Dane wrażliwe
  • Profesjonalne projekty
<?php
session_start(); // ZAWSZE na początku pliku!

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = trim($_POST['username'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';
    
    // Walidacja danych
    if (empty($username)) {
        $errors[] = "Nick jest wymagany";
    } elseif (strlen($username) < 3) {
        $errors[] = "Nick musi mieć minimum 3 znaki";
    }
    
    if (empty($email)) {
        $errors[] = "Email jest wymagany";
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors[] = "Nieprawidłowy format email";
    }
    
    if (empty($password)) {
        $errors[] = "Hasło jest wymagane";
    } elseif (strlen($password) < 8) {
        $errors[] = "Hasło musi mieć minimum 8 znaków";
    }
    
    // Sprawdzamy czy username już istnieje
    if (empty($errors)) {
        $stmt = $db->prepare("SELECT id FROM players WHERE username = ?");
        $stmt->bind_param("s", $username);
        $stmt->execute();
        $stmt->store_result();
        
        if ($stmt->num_rows > 0) {
            $errors[] = "Nick '{$username}' jest już zajęty";
        }
        $stmt->close();
    }
    
    // Jeśli brak błędów - dodajemy gracza
    if (empty($errors)) {
        $password_hash = password_hash($password, PASSWORD_DEFAULT);
        
        $stmt = $db->prepare("INSERT INTO players (username, email, password, created_at) VALUES (?, ?, ?, NOW())");
        $stmt->bind_param("sss", $username, $email, $password_hash);
        
        if ($stmt->execute()) {
            // Pobieramy informacje o operacji
            $new_player_id = $stmt->insert_id;
            $affected_rows = $stmt->affected_rows;
            
            $stmt->close();
            $db->close();
            
            // Zapisujemy dane w sesji
            $_SESSION['registration_success'] = [
                'player_id' => $new_player_id,
                'username' => $username,
                'email' => $email,
                'affected_rows' => $affected_rows,
                'timestamp' => date('Y-m-d H:i:s')
            ];
            
            // Przekierowanie (URL czysty, bez danych)
            header("Location: rejestracja_sukces.php");
            exit;
            
        } else {
            $errors[] = "Wystąpił błąd podczas rejestracji: {$stmt->error}";
        }
        
        $stmt->close();
    }
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja gracza</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            max-width: 500px;
            width: 100%;
        }
        h1 {
            margin-bottom: 30px;
            color: #333;
            text-align: center;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
            font-weight: 500;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 5px;
            font-size: 14px;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 12px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
        }
        button:hover {
            background: #764ba2;
        }
        .error {
            background: #f8d7da;
            color: #721c24;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #f5c6cb;
        }
        .error ul {
            margin-left: 20px;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Rejestracja Gracza</h1>
    
    <?php if (!empty($errors)): ?>
        <div class="error">
            <strong>Błędy walidacji:</strong>
            <ul>
                <?php foreach ($errors as $error): ?>
                    <li><?= htmlspecialchars($error) ?></li>
                <?php endforeach; ?>
            </ul>
        </div>
    <?php endif; ?>
    
    <form method="POST">
        <div class="form-group">
            <label>Nick gracza:</label>
            <input type="text" name="username" 
                   value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" 
                   placeholder="np. ProGamer123" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Email:</label>
            <input type="email" name="email" 
                   value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" 
                   placeholder="twoj@email.com" 
                   required>
        </div>
        
        <div class="form-group">
            <label>Hasło:</label>
            <input type="password" name="password" 
                   placeholder="Min. 8 znaków" 
                   required>
        </div>
        
        <button type="submit">Zarejestruj się</button>
    </form>
</div>

</body>
</html>

Plik: rejestracja_sukces.php

<?php
session_start(); // ZAWSZE na początku!

// Sprawdź czy istnieją dane o rejestracji
if (!isset($_SESSION['registration_success'])) {
    // Jeśli ktoś próbuje wejść bezpośrednio na tę stronę
    header("Location: rejestracja.php");
    exit;
}

// Pobierz dane z sesji
$data = $_SESSION['registration_success'];
$player_id = (int)$data['player_id'];
$username = htmlspecialchars($data['username']);
$email = htmlspecialchars($data['email']);
$affected_rows = (int)$data['affected_rows'];
$timestamp = htmlspecialchars($data['timestamp']);

// Usuń dane z sesji (wyświetl tylko raz)
unset($_SESSION['registration_success']);
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Rejestracja zakończona</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .success-container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.2);
            max-width: 600px;
            width: 100%;
        }
        .success-icon {
            text-align: center;
            font-size: 80px;
            color: #28a745;
            margin-bottom: 20px;
        }
        h1 {
            text-align: center;
            color: #155724;
            margin-bottom: 20px;
        }
        .success-message {
            background: #d4edda;
            color: #155724;
            padding: 20px;
            border-radius: 5px;
            border-left: 4px solid #c3e6cb;
            margin-bottom: 30px;
        }
        .info-box {
            background: #e7f3ff;
            padding: 20px;
            border-radius: 5px;
            border-left: 4px solid #2196F3;
            margin-bottom: 20px;
        }
        .info-box p {
            margin: 8px 0;
            color: #333;
        }
        .info-box strong {
            color: #0c5460;
        }
        .buttons {
            display: flex;
            gap: 10px;
            justify-content: center;
        }
        a {
            padding: 12px 30px;
            background: #667eea;
            color: white;
            text-decoration: none;
            border-radius: 5px;
            font-weight: bold;
            text-align: center;
        }
        a:hover {
            background: #764ba2;
        }
    </style>
</head>
<body>

<div class="success-container">
    <div class="success-icon">✓</div>
    <h1>Rejestracja zakończona sukcesem!</h1>
    
    <div class="success-message">
        <p>Witaj, <strong><?= $username ?></strong>!</p>
        <p>Twoje konto zostało pomyślnie utworzone.</p>
    </div>
    
    <div class="info-box">
        <p><strong>Szczegóły operacji:</strong></p>
        <p>ID gracza: <strong><?= $player_id ?></strong></p>
        <p>Username: <strong><?= $username ?></strong></p>
        <p>Email: <strong><?= $email ?></strong></p>
        <p>Liczba dodanych rekordów: <strong><?= $affected_rows ?></strong></p>
        <p>Data rejestracji: <strong><?= $timestamp ?></strong></p>
    </div>
    
    <div class="buttons">
        <a href="profil.php?id=<?= $player_id ?>">Zobacz profil</a>
        <a href="logowanie.php">Zaloguj się</a>
        <a href="lista_graczy.php">Lista graczy</a>
    </div>
</div>

</body>
</html>

Zalety:

  • Bezpieczne (dane niewidoczne w URL)
  • Można przekazać wiele danych
  • Komunikat wyświetla się tylko raz
  • Profesjonalne rozwiązanie

Wady:

  • Wymaga sesji
  • Trochę więcej kodu

Przykład 4: UPDATE z informacją o affected_rows

<?php
session_start();

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$player_id = (int)($_GET['id'] ?? 0);

if ($player_id <= 0) {
    die('Nieprawidłowe ID gracza');
}

$errors = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $email = trim($_POST['email'] ?? '');
    $level = (int)($_POST['level'] ?? 1);
    $coins = (int)($_POST['coins'] ?? 0);
    
    // Walidacja...
    if (empty($email)) {
        $errors[] = "Email jest wymagany";
    }
    
    if ($level < 1 || $level > 100) {
        $errors[] = "Poziom musi być między 1 a 100";
    }
    
    if (empty($errors)) {
        $stmt = $db->prepare("UPDATE players SET email = ?, level = ?, coins = ? WHERE id = ?");
        $stmt->bind_param("siii", $email, $level, $coins, $player_id);
        
        if ($stmt->execute()) {
            $affected_rows = $stmt->affected_rows;
            
            $stmt->close();
            $db->close();
            
            // Zapisz informacje w sesji
            $_SESSION['update_result'] = [
                'player_id' => $player_id,
                'affected_rows' => $affected_rows,
                'action' => 'update',
                'message' => $affected_rows > 0 
                    ? "Profil został zaktualizowany ({$affected_rows} zmian)" 
                    : "Nie wprowadzono żadnych zmian (dane są identyczne)"
            ];
            
            // Przekierowanie
            header("Location: profil.php?id={$player_id}");
            exit;
        }
        
        $stmt->close();
    }
}

$db->close();
?>

Plik: profil.php (wyświetlanie komunikatu)

<?php
session_start();

$player_id = (int)($_GET['id'] ?? 0);

// Sprawdź czy są dane o aktualizacji
$update_message = '';
$update_data = null;

if (isset($_SESSION['update_result'])) {
    $update_data = $_SESSION['update_result'];
    $update_message = $update_data['message'];
    
    // Usuń z sesji po pobraniu
    unset($_SESSION['update_result']);
}

// Połączenie i pobranie danych gracza...
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$stmt = $db->prepare("SELECT * FROM players WHERE id = ?");
$stmt->bind_param("i", $player_id);
$stmt->execute();
$wynik = $stmt->get_result();
$gracz = $wynik->fetch_assoc();
$stmt->close();
$db->close();

if (!$gracz) {
    die('Nie znaleziono gracza');
}
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Profil - <?= htmlspecialchars($gracz['username']) ?></title>
    <style>
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
        }
        .info {
            background: #d1ecf1;
            color: #0c5460;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #bee5eb;
        }
    </style>
</head>
<body>

<?php if (!empty($update_message)): ?>
    <div class="<?= $update_data['affected_rows'] > 0 ? 'success' : 'info' ?>">
        <?= htmlspecialchars($update_message) ?>
        <?php if ($update_data['affected_rows'] > 0): ?>
            <br><small>Liczba zaktualizowanych rekordów: <?= $update_data['affected_rows'] ?></small>
        <?php endif; ?>
    </div>
<?php endif; ?>

<h1>Profil gracza: <?= htmlspecialchars($gracz['username']) ?></h1>
<p>Email: <?= htmlspecialchars($gracz['email']) ?></p>
<p>Level: <?= $gracz['level'] ?></p>
<p>Coins: <?= number_format($gracz['coins']) ?></p>

<a href="edycja.php?id=<?= $player_id ?>">Edytuj profil</a>

</body>
</html>

Przykład 5: DELETE z potwierdzeniem i informacją o affected_rows

<?php
session_start();

mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$player_id = (int)($_GET['id'] ?? 0);

if ($player_id <= 0) {
    die('Nieprawidłowe ID gracza');
}

// Pobierz dane gracza przed usunięciem
$stmt = $db->prepare("SELECT * FROM players WHERE id = ?");
$stmt->bind_param("i", $player_id);
$stmt->execute();
$wynik = $stmt->get_result();
$gracz = $wynik->fetch_assoc();
$stmt->close();

if (!$gracz) {
    die('Nie znaleziono gracza');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_delete'])) {
    
    $stmt = $db->prepare("DELETE FROM players WHERE id = ?");
    $stmt->bind_param("i", $player_id);
    
    if ($stmt->execute()) {
        $affected_rows = $stmt->affected_rows;
        
        $stmt->close();
        $db->close();
        
        // Zapisz informacje w sesji
        $_SESSION['delete_result'] = [
            'username' => $gracz['username'],
            'player_id' => $player_id,
            'affected_rows' => $affected_rows,
            'action' => 'delete',
            'message' => $affected_rows > 0 
                ? "Gracz '{$gracz['username']}' (ID: {$player_id}) został usunięty" 
                : "Nie usunięto gracza (możliwe że już nie istnieje)"
        ];
        
        // Przekierowanie do listy graczy
        header("Location: lista_graczy.php");
        exit;
    }
    
    $stmt->close();
}

$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Usuń gracza</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
            min-height: 100vh;
            display: flex;
            justify-content: center;
            align-items: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 10px 25px rgba(0,0,0,0.3);
            max-width: 600px;
            width: 100%;
        }
        h1 {
            color: #d32f2f;
            margin-bottom: 20px;
        }
        .warning {
            background: #fff3cd;
            border-left: 4px solid #ffc107;
            padding: 20px;
            margin-bottom: 20px;
            border-radius: 5px;
        }
        .player-info {
            background: #f8f9fa;
            padding: 20px;
            border-radius: 5px;
            margin-bottom: 20px;
        }
        .buttons {
            display: flex;
            gap: 10px;
        }
        button, a {
            flex: 1;
            padding: 15px;
            border: none;
            border-radius: 5px;
            font-size: 16px;
            font-weight: bold;
            cursor: pointer;
            text-align: center;
            text-decoration: none;
            display: inline-block;
        }
        .btn-delete {
            background: #d32f2f;
            color: white;
        }
        .btn-delete:hover {
            background: #b71c1c;
        }
        .btn-cancel {
            background: #6c757d;
            color: white;
        }
        .btn-cancel:hover {
            background: #5a6268;
        }
    </style>
</head>
<body>

<div class="container">
    <h1>Usuń gracza</h1>
    
    <div class="warning">
        <strong>OSTRZEŻENIE:</strong><br>
        Ta operacja jest nieodwracalna! Gracz zostanie permanentnie usunięty z bazy danych.
    </div>
    
    <div class="player-info">
        <h3>Dane gracza do usunięcia:</h3>
        <p><strong>ID:</strong> <?= $gracz['id'] ?></p>
        <p><strong>Username:</strong> <?= htmlspecialchars($gracz['username']) ?></p>
        <p><strong>Email:</strong> <?= htmlspecialchars($gracz['email']) ?></p>
        <p><strong>Level:</strong> <?= $gracz['level'] ?></p>
        <p><strong>Coins:</strong> <?= number_format($gracz['coins']) ?></p>
    </div>
    
    <form method="POST">
        <div class="buttons">
            <a href="profil.php?id=<?= $player_id ?>" class="btn-cancel">Anuluj</a>
            <button type="submit" name="confirm_delete" class="btn-delete" 
                    onclick="return confirm('Czy NA PEWNO chcesz usunąć tego gracza?')">
                Usuń gracza
            </button>
        </div>
    </form>
</div>

</body>
</html>

Plik: lista_graczy.php (wyświetlanie komunikatu po usunięciu)

<?php
session_start();

// Sprawdź czy są dane o usunięciu
$delete_message = '';
$delete_data = null;

if (isset($_SESSION['delete_result'])) {
    $delete_data = $_SESSION['delete_result'];
    $delete_message = $delete_data['message'];
    
    // Usuń z sesji po pobraniu
    unset($_SESSION['delete_result']);
}

// Połączenie i pobranie listy graczy...
mysqli_report(MYSQLI_REPORT_OFF);
$db = @new mysqli('localhost', 'root', '', 'gaming_hub');
$db->set_charset('utf8mb4');

$stmt = $db->prepare("SELECT * FROM players ORDER BY id DESC");
$stmt->execute();
$wynik = $stmt->get_result();
$gracze = $wynik->fetch_all(MYSQLI_ASSOC);
$stmt->close();
$db->close();
?>

<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <title>Lista graczy</title>
    <style>
        .success {
            background: #d4edda;
            color: #155724;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #c3e6cb;
        }
        .warning {
            background: #fff3cd;
            color: #856404;
            padding: 15px;
            border-radius: 5px;
            margin-bottom: 20px;
            border-left: 4px solid #ffeeba;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        th, td {
            padding: 12px;
            border: 1px solid #ddd;
            text-align: left;
        }
        th {
            background: #667eea;
            color: white;
        }
    </style>
</head>
<body>

<h1>Lista graczy</h1>

<?php if (!empty($delete_message)): ?>
    <div class="<?= $delete_data['affected_rows'] > 0 ? 'success' : 'warning' ?>">
        <?= htmlspecialchars($delete_message) ?>
        <?php if ($delete_data['affected_rows'] > 0): ?>
            <br><small>Liczba usuniętych rekordów: <?= $delete_data['affected_rows'] ?></small>
        <?php endif; ?>
    </div>
<?php endif; ?>

<table>
    <tr>
        <th>ID</th>
        <th>Username</th>
        <th>Email</th>
        <th>Level</th>
        <th>Akcje</th>
    </tr>
    <?php foreach ($gracze as $gracz): ?>
        <tr>
            <td><?= $gracz['id'] ?></td>
            <td><?= htmlspecialchars($gracz['username']) ?></td>
            <td><?= htmlspecialchars($gracz['email']) ?></td>
            <td><?= $gracz['level'] ?></td>
            <td>
                <a href="profil.php?id=<?= $gracz['id'] ?>">Profil</a> |
                <a href="edycja.php?id=<?= $gracz['id'] ?>">Edytuj</a> |
                <a href="usun.php?id=<?= $gracz['id'] ?>">Usuń</a>
            </td>
        </tr>
    <?php endforeach; ?>
</table>

</body>
</html>

Porównanie metod przekazywania danych:

MetodaBezpieczeństwoZłożonośćDane w URLWielokrotne odświeżenieKiedy używać
GET (URL)NiskieProsteWidoczneBezpieczneProste ID, niepoufne dane
SESSIONWysokieŚrednieUkryteBezpieczneKomunikaty, wrażliwe dane

Najlepsze praktyki:

  • ZAWSZE używaj przekierowania po INSERT/UPDATE/DELETE
  • Używaj SESSION dla komunikatów i wrażliwych danych
  • Używaj GET tylko dla ID i prostych parametrów
  • Zapisuj insert_id i affected_rows przed przekierowaniem
  • Usuwaj dane z sesji po jednokrotnym wyświetleniu
  • ZAWSZE dodawaj exit; po header()
  • Waliduj dane po stronie odbiorczej (nawet po przekierowaniu)

TaskQuest 1.9 — zadania żyją w bazie: dodawanie, „Zrobione!” i usuwanie

KARTA PRACY

Po Lekcji 18 TaskQuest czyta z bazy wszystko, co pokazuje. Każdy gracz widzi własne zadania, XP i streak. Ale to, co widzi, może zmienić tylko ktoś w phpMyAdmin. Formularz „Dodaj zadanie” jest wyłączony, bo zapisuje do starego pliku CSV. Zadania nie da się oznaczyć jako wykonanego, więc XP stoi w miejscu. Streak jest nadal symulowany.

Dziś TaskQuest zacznie zmieniać dane w bazie. Po tej lekcji gracz dodaje zadania, klika „Zrobione!”, dostaje XP i buduje prawdziwą serię dni. Może też usunąć zadanie.

Cel lekcji

Nauczyć się:

  • dodawać wiersze (INSERT INTO ... VALUES), zmieniać je (UPDATE ... SET ... WHERE) i usuwać (DELETE FROM ... WHERE),
  • wykonywać te polecenia z PHP przez prepared statements i sprawdzać efekt (affected_rows, insert_id),
  • używać WHERE jako bezpiecznika. Warunek właściciela (uzytkownik_id = ?) pilnuje, żeby nikt nie zmienił cudzego zadania. Warunek stanu (data_wykonania IS NULL) pilnuje, żeby XP nie wpadło dwa razy,
  • zmieniać wartość względem obecnej (xp = xp + ?),
  • połączyć zmianę danych z wzorcem POST → przekierowanie → GET z Lekcji 13 i komunikatem jednorazowym z Lekcji 14.

Efekt: formularz dodawania zapisuje do bazy i wraca na stronę. Przy każdym niewykonanym zadaniu jest przycisk „Zrobione!”, który przyznaje XP i aktualizuje streak. Przy każdym zadaniu jest przycisk „Usuń”. Plik CSV i funkcje plikowe znikają z projektu.

Uwaga — co jest, a czego jeszcze nie ma:

  • Rejestracja i hasła przechodzą do Lekcji 22. W Lekcji 18 zapowiadaliśmy hasło już dziś. Zmieniamy plan świadomie: zapisywanie hasła (password_hash()) i jego sprawdzanie przy logowaniu (password_verify()) pojawią się razem. Hasło, którego logowanie przez trzy lekcje nie sprawdza, dawałoby tylko pozór bezpieczeństwa. Logowanie zostaje więc takie jak po Lekcji 18.
  • Dziś nie ma edycji zadania (zmiana tytułu, terminu). To zadanie końcowe A.
  • „Zrobione!” zmienia dwie tabele dwoma zapytaniami. Co się stanie, jeśli drugie się nie uda, zobaczysz w Eksperymencie 5. Rozwiązanie (transakcja) to zadanie końcowe C.

Rozgrzewka — 4 pytania z Lekcji 18

  1. Dlaczego login z formularza przekazujemy do zapytania przez bind_param(), a nie wklejamy do tekstu SQL?
  2. wczytajUzytkownika($polaczenie, 99) zwraca null. Czy to awaria? Jak reaguje na to index.php?
  3. Po czym w bazie poznajemy, że zadanie jest wykonane? Jak w SQL wybrać tylko zadania niewykonane i dlaczego nie = NULL?
  4. Dlaczego w sesji zapisujemy tylko uzytkownik_id, a nie login i imię?

1. Wracamy do naszego TaskQuest

Co zostaje, co dochodzi, co zmieniamy

ElementPo Lekcji 18Po Lekcji 19
baza taskquest, sql/taskquest.sqlaplikacja tylko czytazostaje bez zmian; aplikacja także zapisuje
includes/funkcje_bazy.phppołączenie + 4 funkcje z SELECTdochodzi 5 funkcji: INSERT, UPDATE ×2, DELETE i SELECT jednego zadania
dodaj-zadanie.phpzapisuje do CSV, niedostępny z nawigacjizmiana: zapis do bazy, link „+ Dodaj zadanie” wraca
oznacz-wykonane.php, usun-zadanie.phpnie madochodzą (bez HTML, jak wyloguj.php)
Uzytkownik::aktualizujStreak()przelicza streak przy każdym wyświetleniu, na podstawie symulacjizmiana: liczy streak raz, w chwili wykonania zadania
$zadanieWykonane w index.phpsymulacja „aktywności”usuwamy — aktywność to kliknięcie „Zrobione!”
data/zadania.csv, funkcje_plikow.php, PLIK_ZADAN, nastepneIdZadania()używane tylko przez formularzusuwamy — nic już z nich nie korzysta
walidacja formularza (walidujNoweZadanie()), funkcja aktualizujStreak(), klasy, logowanie, sesjaistniejązostają bez zmian

Zasada z Lekcji 18 obowiązuje dalej: obiekty i logika zostają, zmienia się miejsce, w którym żyją dane. Walidacja formularza, obliczanie poziomu i liczenie serii dni to kod, który już znasz.

Docelowa struktura projektu

taskquest/
├── index.php                        (zmiana: link „+ Dodaj zadanie”, przyciski „Zrobione!” i „Usuń”, bez symulacji streaka)
├── dodaj-zadanie.php                (zmiana: zapis do bazy)
├── oznacz-wykonane.php              NOWY
├── usun-zadanie.php                 NOWY (zadanie z sekcji 6)
├── zaloguj.php                      bez zmian
├── wyloguj.php                      bez zmian
├── diagnostyka.php                  (zmiana: lista plików, bez sekcji „Pliki danych”)
├── classes/
│   ├── Zadanie.php                  bez zmian
│   └── Uzytkownik.php               (zmiana: aktualizujStreak())
├── config/
│   └── config.php                   (zmiana: bez PLIK_ZADAN)
├── includes/
│   ├── header.php                   (zmiana: styl form.akcja)
│   ├── footer.php                   (zmiana: „TaskQuest 1.9”)
│   ├── funkcje_bazy.php             (zmiana: 5 nowych funkcji)
│   ├── funkcje_zadan.php            (zmiana: bez nastepneIdZadania())
│   ├── funkcje_plikow.php           USUWAMY
│   └── ... pozostałe                bez zmian
├── sql/
│   └── taskquest.sql                bez zmian
└── data/                            USUWAMY (razem z zadania.csv)

Przygotowanie (zrób to teraz)

  1. Zrób kopię zapasową katalogu taskquest/ jako taskquest_lekcja18/ (obok, nie w środku).
  2. Uruchom w XAMPP Apache i MySQL.
  3. W phpMyAdmin uruchom ponownie cały sql/taskquest.sql. Dziś będziemy często psuć dane testowe. Ten skrypt przywraca je w kilka sekund.

2. Problem w aplikacji

Kama zrobiła wczoraj zadanie „Nauczyć się regexów PHP”. Loguje się do TaskQuest i chce je odhaczyć. Nie ma jak. Na liście jest [ ], XP stoi na 99. Zmienić to może tylko ktoś, kto wejdzie do phpMyAdmin i ręcznie przepisze liczby.

Ola chce dodać nowe zadanie. Formularz istnieje, ale zniknął z nawigacji, a pod bezpośrednim adresem zapisuje do pliku, którego strona już nie czyta.

TaskQuest jest na razie tablicą wyników, którą ktoś uzupełnia ręcznie. Żeby był grą, aplikacja musi umieć:

  1. dodać wiersz do tabeli zadania (nowe zadanie),
  2. zmienić wiersz: wpisać datę wykonania zadania, dopisać graczowi XP i zaktualizować serię dni,
  3. usunąć wiersz (zadanie niepotrzebne).

Są też dwa haczyki, o których pomyślimy od początku. Po pierwsze, nikt nie może zmienić cudzego zadania. Po drugie, zadanie wykonane raz nie może dawać XP drugi raz, nawet jeśli ktoś kliknie dwa razy.

3. Nowy mechanizm: INSERT, UPDATE, DELETE

3.1 Trzy polecenia w phpMyAdmin

Otwórz phpMyAdmin → baza taskquest → zakładka SQL. Przed każdym poleceniem zapisz, co Twoim zdaniem się stanie.

INSERT — dodaj wiersz

INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp)
VALUES (2, 'Przetestować INSERT', '', '2026-10-10', 10);
  • Po nazwie tabeli wymieniamy kolumny, po VALUES wartości w tej samej kolejności. To trochę jak argumenty nazwane z Lekcji 15: kolejność w bazie nie ma znaczenia, liczy się dopasowanie nazwy do wartości.
  • Brakuje dwóch kolumn: id i data_wykonania. id nada baza (AUTO_INCREMENT). data_wykonania dostanie NULL, bo kolumna ma NULL bez innej wartości domyślnej. Nowe zadanie jest więc od razu niewykonane.

Sprawdź: SELECT id, tytul, data_wykonania FROM zadania WHERE uzytkownik_id = 2;. Ola ma teraz dwa zadania. Zapamiętaj id nowego.

UPDATE — zmień wiersz

UPDATE zadania
SET data_wykonania = CURDATE()
WHERE uzytkownik_id = 2 AND tytul = 'Przetestować INSERT';
  • SET kolumna = wartość mówi, co zmienić. WHERE mówi, w których wierszach.
  • CURDATE() to funkcja SQL: dzisiejsza data według serwera MySQL, w formacie RRRR-MM-DD.
  • phpMyAdmin pokaże „Zmieniono rekordów: 1” (w wersji angielskiej „1 row affected”).

Uruchom to samo polecenie jeszcze raz. Przewidź wynik, zanim klikniesz. Teraz zobaczysz „Zmieniono rekordów: 0”. Wiersz pasował do WHERE, ale data już była dzisiejsza, więc nic się nie zmieniło. MySQL liczy zmienione wiersze, nie pasujące. Ta różnica przyda się w TaskQuest.

Teraz ten sam UPDATE z dodatkowym warunkiem:

UPDATE zadania
SET data_wykonania = CURDATE()
WHERE uzytkownik_id = 2 AND tytul = 'Przetestować INSERT' AND data_wykonania IS NULL;

Wynik: 0. Warunek mówi „zmień tylko, jeśli zadanie jeszcze nie jest wykonane”. Zapamiętaj ten wzór — od niego zależy, czy gracz dostanie XP raz, czy wiele razy.

UPDATE względem obecnej wartości

UPDATE uzytkownicy SET xp = xp + 10 WHERE id = 2;

Po prawej stronie = stoi stara wartość kolumny. Ola miała 250, ma 260. Uruchom jeszcze raz — 270. Nie musimy znać obecnej liczby XP, żeby ją zwiększyć. Baza dolicza do tego, co ma w chwili zmiany.

DELETE — usuń wiersz

DELETE FROM zadania WHERE uzytkownik_id = 2 AND tytul = 'Przetestować INSERT';

phpMyAdmin zapyta o potwierdzenie. Tak ma być: DELETE jest nieodwracalny, nie ma kosza.

Najważniejsze zdanie tej lekcji: UPDATE i DELETE bez WHERE działają na wszystkich wierszach tabeli. Nie ma pytania „czy na pewno?” w PHP. Zobaczysz to w Eksperymencie 1.

Uruchom ponownie sql/taskquest.sql — Ola wraca do 250 XP.

3.2 Te same polecenia z PHP

Z SELECT znasz cztery kroki: prepare() → bind_param() → execute() → get_result(). Przy INSERT, UPDATE i DELETE baza nie zwraca wierszy, więc ostatniego kroku nie ma. Zamiast wyniku sprawdzamy, ile wierszy się zmieniło.

Utwórz brudnopis.php w katalogu taskquest/:

<?php

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

$polaczenie = polaczZBaza();

$premia = 5;
$uzytkownikId = 2;

$zapytanie = $polaczenie->prepare('UPDATE uzytkownicy SET xp = xp + ? WHERE id = ?');
$zapytanie->bind_param('ii', $premia, $uzytkownikId);
$zapytanie->execute();

var_dump($zapytanie->affected_rows);

Oczekiwane: int(1). Sprawdź w phpMyAdmin: Ola ma 255 XP.

Teraz dwie próby:

  1. Odśwież brudnopis trzy razy. Ile XP ma Ola? (270.) Każde odświeżenie to nowe żądanie, a każde żądanie wykonuje UPDATE od nowa. Dlatego od Lekcji 13 dane zmieniamy wyłącznie przez POST + przekierowanie, nigdy przez zwykłe wejście na adres.
  2. Zmień $uzytkownikId na 99. Wynik: int(0) i żaden wyjątek. Zapytanie jest poprawne, tylko żaden wiersz nie pasował. To ta sama sytuacja co null z wczytajUzytkownika() w Lekcji 18: brak pasujących danych to nie awaria.

Co mówi affected_rows:

Polecenieaffected_rows to…
INSERTliczba dodanych wierszy (u nas zawsze 1)
UPDATEliczba wierszy, które się naprawdę zmieniły (nie tych, które pasowały do WHERE)
DELETEliczba usuniętych wierszy

Błąd zapytania (literówka, zła wartość) nie daje specjalnej liczby. Od Lekcji 18 polaczZBaza() ustawia MYSQLI_REPORT_STRICT, więc błąd to wyjątek.

Po INSERT przydaje się jeszcze jedna właściwość: $zapytanie->insert_id. To id, które baza nadała nowemu wierszowi przez AUTO_INCREMENT. Nie musimy go zgadywać, jak robiła to nastepneIdZadania() przy CSV.

Uruchom ponownie sql/taskquest.sql.

3.3 WHERE jako bezpiecznik

W TaskQuest każde UPDATE i DELETE na zadaniu będzie miało warunek na dwie rzeczy naraz.

1. Czyje to zadanie? Przycisk „Zrobione!” wyśle do serwera id zadania w ukrytym polu formularza. „Ukryte” znaczy tylko „niewidoczne na stronie”. Każdy może je zmienić w narzędziach przeglądarki (F12) i wysłać id zadania Kamy, będąc zalogowanym jako Ola. Zasada z Lekcji 13 obowiązuje: danym z formularza nie ufamy. Ufamy za to $_SESSION['uzytkownik_id'], bo zapisał go nasz kod. Dlatego:

WHERE id = ? AND uzytkownik_id = ?

Jeśli ktoś podrobi id, warunek nie pasuje, zmienia się 0 wierszy. Prepared statement tu nie pomoże — to nie SQL injection, tylko brak sprawdzenia uprawnień (Eksperyment 3).

2. Czy wolno to jeszcze zmienić? XP ma wpaść tylko raz. Najprostszy pomysł to sprawdzić w PHP: wczytać zadanie, if (!$zadanie->czyWykonane()), potem UPDATE. Ale co, jeśli gracz ma TaskQuest otwarty w dwóch kartach i kliknie „Zrobione!” w obu? Oba żądania wczytają zadanie jako niewykonane, oba przejdą przez if, oba dodadzą XP.

Dlatego o tym, czy zmiana się należy, decyduje ten sam UPDATE, który zmienia dane:

UPDATE zadania SET data_wykonania = CURDATE()
WHERE id = ? AND uzytkownik_id = ? AND data_wykonania IS NULL

InnoDB blokuje wiersz na czas takiej zmiany. Drugie żądanie poczeka, zobaczy już wpisaną datę i dostanie affected_rows = 0. W PHP przyznajemy XP tylko wtedy, gdy affected_rows wynosi 1. Sprawdzisz to w Teście 4 i Eksperymencie 2.

Typowe błędy

  • Brak WHERE w UPDATE/DELETE — zmiana wszystkich wierszy (Eksperyment 1).
  • Stała albo wyrażenie w bind_param(): bind_param('ii', 5, $uzytkownikId) albo bind_param('i', (int) $xpZadania) kończy się Error („could not be passed by reference”). bind_param() przyjmuje tylko zmienne. Wartość zapisz najpierw w zmiennej. (W funkcji to się nie zdarza, bo parametry funkcji to zmienne.)
  • Liczba liter w bind_param() różna od liczby ? — ArgumentCountError albo błąd zapytania. Liczymy ? od lewej i dla każdego piszemy literę i (liczba całkowita) albo s (tekst, także data).
  • get_result() po UPDATE/DELETE — nie ma wyniku do odczytania. Efekt sprawdzamy przez affected_rows.
  • affected_rows > 0 jako „wiersz istnieje” — przy UPDATE 0 może też znaczyć „wiersz istnieje, ale wartość była taka sama” (3.1).

4. SAMODZIELNIE — pierwsze zadanie dodane z PHP

W brudnopis.php dodaj do bazy zadanie dla Oli (uzytkownik_id = 2):

  • tytuł Zadanie z brudnopisu, opis pusty, termin 2026-10-15, 15 XP,
  • dane zapisz najpierw w zmiennych ($uzytkownikId, $tytul, $opis, $termin, $xp) — tak, jakby przyszły z formularza,
  • zapytanie przez prepared statement,
  • na końcu wypisz affected_rows i insert_id.

Potem zaloguj się do TaskQuest jako Ola_TQ. Zadanie z brudnopisu powinno być na liście, bez żadnych zmian w index.php. Strona czyta z bazy od Lekcji 18.

HINT

Kolumn podajesz pięć, więc w VALUES będzie pięć znaków ?. Termin to tekst w formacie RRRR-MM-DD — jaką literą go oznaczysz?

POMOC
$zapytanie = $polaczenie->prepare(
    'INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp) VALUES (?, ?, ?, ?, ?)'
);
$zapytanie->bind_param('.....', ...);

Litery w bind_param() idą w tej samej kolejności co ?: liczba, tekst, tekst, tekst, liczba.

ROZWIĄZANIE
<?php

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

$polaczenie = polaczZBaza();

$uzytkownikId = 2;
$tytul = 'Zadanie z brudnopisu';
$opis = '';
$termin = '2026-10-15';
$xp = 15;

$zapytanie = $polaczenie->prepare(
    'INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp) VALUES (?, ?, ?, ?, ?)'
);
$zapytanie->bind_param('isssi', $uzytkownikId, $tytul, $opis, $termin, $xp);
$zapytanie->execute();

var_dump($zapytanie->affected_rows);
var_dump($zapytanie->insert_id);

Oczekiwane: int(1) i liczba o jeden większa od największego id w tabeli zadania.

Odśwież brudnopis jeszcze raz. Co się stało? Ola ma dwa identyczne zadania, z różnymi id. Baza nie wie, że „to samo zadanie” już jest. Każde wykonanie INSERT to nowy wiersz. Dlatego formularz w TaskQuest kończy się przekierowaniem (POST → przekierowanie → GET, Lekcja 13). Odświeżenie strony po dodaniu nie wyśle formularza drugi raz.

Sprawdź też: zmień $uzytkownikId na 99 i uruchom. Wynik: wyjątek z komunikatem o kluczu obcym (Cannot add or update a child row: a foreign key constraint fails...). Baza nie pozwala dodać zadania użytkownikowi, którego nie ma. To relacja z Lekcji 17 w działaniu. Przywróć 2.

Uruchom ponownie sql/taskquest.sql.

5. Włączamy zapis do TaskQuest

Od tego miejsca pracujesz na projekcie. brudnopis.php zostaw — przyda się do testowania nowych funkcji.

Plan: najpierw dodawanie zadań i sprzątanie po CSV (kroki 1–3), potem „Zrobione!” z XP i streakiem (kroki 4–7), na końcu diagnostyka (krok 8). Po każdym kroku aplikacja działa.

Krok 1 — SAMODZIELNIE: funkcja dodajZadanie()

Kod z brudnopisu (sekcja 4) zamienisz w funkcję. W kroku 2 formularz wywoła ją jedną linią, zamiast budować zapytanie od nowa.

Co zrobić:

1. Otwórz includes/funkcje_bazy.php. W komentarzu na górze pliku zamień (Lekcja 18) na (Lekcje 18–19).

2. Pod funkcją pobierzRanking() utwórz funkcję z takim nagłówkiem:

function dodajZadanie(mysqli $polaczenie, int $uzytkownikId, string $tytul, string $opis, string $termin, int $xp): int

3. Przenieś do niej z brudnopisu tylko linie, które wykonują zapytanie. Przy każdej linii brudnopisu zadaj sobie pytanie: czy funkcja jej potrzebuje, czy tę wartość dostaje już w parametrze?
4. Na końcu funkcja ma zwrócić id nowego zadania (3.2).
5. Zasady są te same co dla funkcji z Lekcji 18. Funkcja nie łączy się sama z bazą, niczego nie wyświetla, nie łapie wyjątków i nie sprawdza danych, bo dostaje je już sprawdzone przez walidujNoweZadanie().

Sprawdzenie. W brudnopisie zostaw dwa require_once i $polaczenie = polaczZBaza();, a resztę zastąp:

var_dump(dodajZadanie($polaczenie, 2, 'Test funkcji', '', '2026-10-20', 5));

Oczekiwane: int(...) — nowe id. W phpMyAdmin Ola ma zadanie „Test funkcji”. Uruchom ponownie sql/taskquest.sql.

HINT

Z brudnopisu nie przenosisz: require_once, polaczZBaza(), pięciu linii z wartościami ($uzytkownikId = 2; itd. — to teraz parametry funkcji) i var_dump(). Zostają trzy kroki: prepare(), bind_param(), execute().

ROZWIĄZANIE
function dodajZadanie(
    mysqli $polaczenie,
    int $uzytkownikId,
    string $tytul,
    string $opis,
    string $termin,
    int $xp
): int {
    $zapytanie = $polaczenie->prepare(
        'INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp) VALUES (?, ?, ?, ?, ?)'
    );
    $zapytanie->bind_param('isssi', $uzytkownikId, $tytul, $opis, $termin, $xp);
    $zapytanie->execute();

    return $zapytanie->insert_id;
}

data_wykonania nie jest parametrem, bo nowe zadanie jest zawsze niewykonane. Zwracanego id formularz nie potrzebuje, ale przydaje się w testach (i w zadaniu końcowym A).

Krok 2 — dodaj-zadanie.php zapisuje do bazy

Otwórz dodaj-zadanie.php. Zmieniamy tylko część PHP, i to niewiele.

2a. Dołączane pliki. Usuń dwie linie:

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

a pod require_once pliku funkcje_sesji.php dopisz:

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

Strona nie tworzy już obiektu Zadanie (zapisuje dane prosto do bazy), więc nie potrzebuje klasy.

2b. SAMODZIELNIE: zapis do bazy. Znajdź w dodaj-zadanie.php warunek if (count($bledy) === 0) {. W nim jest blok try, który dziś zapisuje do CSV w trzech krokach: wczytajZadania(PLIK_ZADAN) → $zadania[] = new Zadanie(...) → zapiszZadania(...). Pod nimi są jeszcze trzy linie: ustawKomunikat(...), header(...) i exit;.

Co zrobić:

  1. Usuń z try trzy linie zapisu do CSV (wczytanie listy, dopisanie obiektu, zapis listy). Trzy linie komunikatu, przekierowania i exit zostaw. Cały catch też zostaw bez zmian.
  2. W ich miejsce (na początku try) połącz się z bazą i wywołaj dodajZadanie() z kroku 1. Przekaż jej połączenie, id zalogowanego gracza i cztery dane z formularza.
  3. Przy każdym argumencie sprawdź typ, którego wymaga nagłówek funkcji. $xpZadania to tekst z formularza, a funkcja chce int.

Odczyt pól z $_POST i wywołanie walidujNoweZadanie() zostają bez zmian.

Sprawdzenie — punkt 2d poniżej.

HINT

Id zalogowanego gracza nie przychodzi z formularza, tylko z sesji (Lekcja 18: jedyny klucz tożsamości). Tekst z samymi cyframi zamieniasz na liczbę tak samo jak robiła to dotąd linia z new Zadanie(...).

ROZWIĄZANIE
        try {
            $polaczenie = polaczZBaza();
            dodajZadanie(
                $polaczenie,
                $_SESSION['uzytkownik_id'],
                $tytulZadania,
                $opisZadania,
                $terminZadania,
                (int) $xpZadania
            );

            ustawKomunikat('Zadanie zostało dodane.');
            header('Location: index.php');
            exit;
        } catch (Exception $wyjatek) {
            $bladZapisu = $wyjatek->getMessage();
        }

Co zniknęło i dlaczego:

  • wczytywanie całej listy przed zapisem — przy CSV trzeba było wczytać plik, dopisać zadanie i zapisać wszystko od nowa (tryb w). Baza dopisuje jeden wiersz, reszty nie dotyka,
  • nastepneIdZadania() — id nadaje AUTO_INCREMENT,
  • lokalna zmienna $zadania — niepotrzebna.

Co zostało: walidacja, zachowywanie wpisanych danych w formularzu, ramka $bladZapisu („Nie udało się dodać zadania: …”), komunikat w sesji i przekierowanie. (int) $xpZadania jest bezpieczne, bo walidacja sprawdziła regexem /^\d+$/, że to same cyfry.

Właściciela zadania bierzemy z sesji, nie z formularza. W formularzu nie ma (i nie będzie) pola „dla kogo” — nikt nie doda zadania na cudze konto.

Po co walidacja, skoro baza sama odrzuci za długi tytuł? Trzy powody. Baza zgłasza wyjątek z technicznym komunikatem, a użytkownik ma dostać listę zrozumiałych błędów (reguła z Lekcji 13). Zakres XP 1–100 sprawdza tylko PHP (Lekcja 17). Walidacja pokazuje wszystkie błędy naraz, baza zatrzymuje się na pierwszym.

2c. Link wraca. W index.php, pod <h2>Twoje zadania</h2>, przywróć:

<p><a href="dodaj-zadanie.php">+ Dodaj zadanie</a></p>

2d. Sprawdzenie. Zaloguj się jako Kama, dodaj zadanie. Oczekiwane: komunikat „Zadanie zostało dodane.”, nowe zadanie na liście. Zaloguj się jako Ola — tego zadania nie widzi.

Krok 3 — sprzątanie po CSV

Od tej chwili nic w TaskQuest nie czyta ani nie zapisuje data/zadania.csv. Martwy kod myli: za miesiąc nikt nie będzie pamiętał, czy ten plik jest jeszcze potrzebny. Dane z CSV są bezpieczne — w Lekcji 17 trafiły do sql/taskquest.sql.

Zanim usuniesz, sprawdź. W edytorze wyszukaj w całym projekcie (VS Code: Ctrl+Shift+F) każdą z nazw:

PLIK_ZADAN
wczytajZadania(
zapiszZadania(
sprawdzWierszZadania(
nastepneIdZadania(
funkcje_plikow

Wpisz wczytajZadania( razem z nawiasem. Bez nawiasu wyszukiwarka znajdzie też wczytajZadaniaUzytkownika. Oczekiwane: nazwy występują tylko w definicjach i w diagnostyka.php (tę poprawisz w kroku 8). Jeśli znajdziesz inne miejsce, najpierw wróć do kroku 2.

Usuń:

  1. plik includes/funkcje_plikow.php (funkcje wczytajZadania(), zapiszZadania(), sprawdzWierszZadania()),
  2. katalog data/ razem z zadania.csv,
  3. w config/config.php linię define('PLIK_ZADAN', ...);,
  4. w includes/funkcje_zadan.php całą funkcję nastepneIdZadania(). walidujNoweZadanie() i funkcje listy zostają.

Odśwież index.php i dodaj jeszcze jedno zadanie — wszystko ma działać jak po kroku 2.

Krok 4 — SAMODZIELNIE: jedno zadanie i jego wykonanie

„Zrobione!” potrzebuje dwóch funkcji w funkcje_bazy.php. Napisz je pod dodajZadanie().

Funkcja 1:

function wczytajZadanieUzytkownika(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): ?Zadanie
  • zwraca obiekt Zadanie o podanym id, ale tylko jeśli należy do podanego użytkownika,
  • w każdym innym przypadku (nie ma takiego zadania, jest cudze) zwraca null.

Funkcja 2:

function oznaczZadanieJakoWykonane(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
  • wpisuje dzisiejszą datę w data_wykonania,
  • tylko w zadaniu tego użytkownika i tylko jeśli zadanie nie jest jeszcze wykonane,
  • zwraca true, jeśli to wywołanie zmieniło wiersz, a false w każdym innym przypadku.

Test w brudnopisie (dołącz też classes/Zadanie.php). id zadania Oli odczytaj w phpMyAdmin (po świeżym taskquest.sql to ostatni wiersz tabeli zadania):

$zadanieOli = 6; // wpisz id zadania Oli ze swojej bazy

var_dump(wczytajZadanieUzytkownika($polaczenie, $zadanieOli, 1));
var_dump(wczytajZadanieUzytkownika($polaczenie, $zadanieOli, 2)->pobierzTytul());

var_dump(oznaczZadanieJakoWykonane($polaczenie, $zadanieOli, 1));
var_dump(oznaczZadanieJakoWykonane($polaczenie, $zadanieOli, 2));
var_dump(oznaczZadanieJakoWykonane($polaczenie, $zadanieOli, 2));

Przewidź pięć wyników, potem uruchom. Oczekiwane: NULL (zadanie Oli, a pytamy jako Kama), string(28) "Zaprojektować bazę TaskQuest", false (Kama nie może oznaczyć zadania Oli), true, false (już wykonane). Na koniec uruchom ponownie sql/taskquest.sql.

HINT

Funkcja 1 to połączenie dwóch funkcji z Lekcji 18: zapytanie i tworzenie obiektu jak w wczytajZadaniaUzytkownika(), a obsługa „jeden wiersz albo nic” jak w wczytajUzytkownika(). Funkcja 2 to UPDATE z trzema warunkami z 3.3. Wynik funkcji to jedno porównanie.

ROZWIĄZANIE
function wczytajZadanieUzytkownika(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): ?Zadanie
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, tytul, opis, termin, data_wykonania, xp FROM zadania WHERE id = ? AND uzytkownik_id = ?'
    );
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

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

    return new Zadanie(
        id: $wiersz['id'],
        tytul: $wiersz['tytul'],
        opis: $wiersz['opis'],
        termin: $wiersz['termin'],
        wykonane: $wiersz['data_wykonania'] !== null,
        xp: $wiersz['xp'],
    );
}

function oznaczZadanieJakoWykonane(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
{
    $zapytanie = $polaczenie->prepare(
        'UPDATE zadania SET data_wykonania = CURDATE() WHERE id = ? AND uzytkownik_id = ? AND data_wykonania IS NULL'
    );
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
}
  • „Nie ma takiego zadania” i „zadanie jest cudze” dają ten sam wynik: null. Celowo. Ola nie powinna się dowiedzieć, że zadanie o id 3 istnieje, tylko należy do kogoś innego.
  • oznaczZadanieJakoWykonane() zwraca false z trzech powodów: nie ma zadania, jest cudze albo już wykonane. Funkcja ich nie rozróżnia. Strona, która ją wywoła, zrobi to sama dzięki funkcji 1.

Krok 5 — prawdziwy streak i XP

5a. Jak dotąd liczyliśmy streak

Funkcja aktualizujStreak() z funkcje_terminow.php zostaje bez zmian:

function aktualizujStreak(int $streak, string $dataOstatniejAktywnosci, bool $zadanieWykonane): int {
    if (!$zadanieWykonane) return $streak;

    $dzisiaj = new DateTime('today');
    $wczoraj = new DateTime('yesterday');
    $ostatniaAktywnosc = new DateTime($dataOstatniejAktywnosci);

    if ($ostatniaAktywnosc->format('Y-m-d') === $dzisiaj->format('Y-m-d')) return $streak;

    if ($ostatniaAktywnosc->format('Y-m-d') === $wczoraj->format('Y-m-d')) return $streak + 1;

    return 1;
}

Przypomnienie reguł: aktywność dziś — seria bez zmian; wczoraj — seria +1; wcześniej — seria zaczyna się od nowa (1).

Do tej pory index.php przy każdym wyświetleniu wywoływał $uzytkownik->aktualizujStreak($zadanieWykonane), a $zadanieWykonane znaczyło „na liście jest choć jedno wykonane zadanie”. To była symulacja: nie wiedzieliśmy, kiedy gracz coś zrobił. Wynik nigdzie się nie zapisywał, więc każde odświeżenie liczyło od nowa.

Teraz wiemy dokładnie: gracz był aktywny w chwili, gdy kliknął „Zrobione!”. Streak liczymy więc raz, w tym momencie, i zapisujemy do bazy razem z datą aktywności. Strona główna tylko go pokazuje.

5b. Zmiana w classes/Uzytkownik.php

Otwórz classes/Uzytkownik.php i znajdź metodę aktualizujStreak() (ostatnio zmienianą w Lekcji 18, krok 4). Zmień ją w czterech miejscach:

  1. Nagłówek. Usuń parametr: aktualizujStreak(bool $zadanieWykonane): void → aktualizujStreak(): void. Metodę będziemy wywoływać tylko po wykonaniu zadania, więc parametr zawsze miałby wartość true.
  2. Warunek null. Dziś jest w nim samo return; z komentarzem „brak aktywności — … streak zostaje taki jak w bazie”. Przed return; dopisz linię, która ustawia właściwość streak obiektu na 1. Zmień komentarz na: // pierwsze wykonane zadanie w historii — seria się zaczyna. Od teraz null znaczy „gracz jeszcze nigdy nic nie wykonał”, a ta metoda jest wywoływana właśnie wtedy, gdy wykonuje pierwsze zadanie.
  3. Wywołanie funkcji. W ostatniej linii metody trzeci argument funkcji aktualizujStreak(...) to $zadanieWykonane. Tej zmiennej już nie ma — wpisz w jej miejsce true.
  4. Komentarz nad metodą (linia nad public function): // Wywołujemy tylko wtedy, gdy użytkownik właśnie wykonał zadanie (oznacz-wykonane.php).

Nazwa metody się nie zmienia. Nadal aktualizuje streak według reguł funkcji o tej samej nazwie.

data_ostatniej_aktywnosci przed kliknięciemstreak przedstreak po
dziś55
wczoraj56
trzy dni temu51
NULL01

Sprawdzenie w brudnopisie (dołącz classes/Uzytkownik.php, includes/funkcje_grywalizacji.php, includes/funkcje_terminow.php):

$wczoraj = new DateTime('yesterday');

$uzytkownik = new Uzytkownik(
    id: 1,
    login: 'Kama_2026',
    imieUzytkownika: 'Kama',
    xp: 99,
    streak: 5,
    dataOstatniejAktywnosci: $wczoraj->format('Y-m-d'),
);
$uzytkownik->aktualizujStreak();
var_dump($uzytkownik->pobierzStreak());

Oczekiwane: int(6). Sprawdź pozostałe wiersze tabeli: zamień datę na '2026-01-01', a potem na null.

index.php nadal wywołuje $uzytkownik->aktualizujStreak($zadanieWykonane). PHP nie zgłosi błędu (nadmiarowy argument jest pomijany), ale strona może teraz pokazywać zawyżony streak. Usuniemy to wywołanie w kroku 7a.

ROZWIĄZANIE – metoda po zmianach
    // Wywołujemy tylko wtedy, gdy użytkownik właśnie wykonał zadanie (oznacz-wykonane.php).
    public function aktualizujStreak(): void
    {
        if ($this->dataOstatniejAktywnosci === null) {
            $this->streak = 1; // pierwsze wykonane zadanie w historii — seria się zaczyna
            return;
        }

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

5c. SAMODZIELNIE: funkcja zapiszPostepUzytkownika()

Po wykonaniu zadania w tabeli uzytkownicy muszą się zmienić trzy kolumny gracza:

  • xp — dodajemy nagrodę za zadanie,
  • streak — wpisujemy nową wartość, policzoną w 5b,
  • data_ostatniej_aktywnosci — dzisiejsza data.

W funkcje_bazy.php, pod oznaczZadanieJakoWykonane(), napisz funkcję:

function zapiszPostepUzytkownika(mysqli $polaczenie, int $uzytkownikId, int $zdobyteXp, int $streak): void

Wymagania:

  1. Jedno zapytanie UPDATE zmienia wszystkie trzy kolumny.
  2. XP dodajesz do wartości, która jest w bazie (wzór xp = xp + ... z 3.1), a nie wpisujesz gotowej liczby.
  3. Streak wpisujesz dokładnie taki, jaki funkcja dostała w parametrze.
  4. Datę ustawia funkcja SQL — ta sama, której używa oznaczZadanieJakoWykonane().
  5. Zmienia się tylko wiersz tego jednego użytkownika.

Sprawdzenie w brudnopisie:

zapiszPostepUzytkownika($polaczenie, 2, 30, 1);

Oczekiwane w phpMyAdmin: Ola ma 280 XP, streak 1 i dzisiejszą datę aktywności. Kama bez zmian. Uruchom ponownie sql/taskquest.sql.

HINT

W SET kolumny oddzielasz przecinkami: SET a = ..., b = ..., c = .... Znaków ? będą trzy. Policz, w jakiej kolejności stoją w zapytaniu, i w tej samej kolejności ułóż litery i zmienne w bind_param().

POMOC
UPDATE uzytkownicy SET xp = xp + ?, streak = ?, data_ostatniej_aktywnosci = ... WHERE ...
ROZWIĄZANIE
function zapiszPostepUzytkownika(mysqli $polaczenie, int $uzytkownikId, int $zdobyteXp, int $streak): void
{
    $zapytanie = $polaczenie->prepare(
        'UPDATE uzytkownicy SET xp = xp + ?, streak = ?, data_ostatniej_aktywnosci = CURDATE() WHERE id = ?'
    );
    $zapytanie->bind_param('iii', $zdobyteXp, $streak, $uzytkownikId);
    $zapytanie->execute();
}

Dlaczego XP dodaje SQL, a streak liczy PHP?

  • XP. Moglibyśmy wywołać $uzytkownik->dodajXp(...) i zapisać gotową liczbę (xp = ?). Problem: obiekt zna XP z chwili wczytania. Jeśli w tym samym czasie drugie żądanie tego gracza doda XP (druga karta), zapis gotowej liczby nadpisze tamten wynik i XP przepadnie. xp = xp + ? dolicza do tego, co jest w bazie w chwili zapisu.
  • Streak. Reguły serii są w PHP (funkcja aktualizujStreak()) i nie przepisujemy ich na SQL.
  • Data. CURDATE(), ta sama funkcja co przy data_wykonania. Obie daty ustawia ten sam zegar (MySQL).

Krok 6 — strona oznacz-wykonane.php

Ta strona robi jedną rzecz: przyjmuje kliknięcie „Zrobione!”, zapisuje zmiany w bazie i odsyła gracza na listę. Niczego nie wyświetla (poza awarią bazy), podobnie jak wyloguj.php. Zbudujemy ją w pięciu krokach, a po niektórych od razu sprawdzisz efekt. Cały plik znajdziesz w sekcji 9 — zajrzyj tam dopiero po skończeniu.

6a. Nowy plik i dołączane pliki

  1. W katalogu taskquest/ (obok index.php) utwórz plik oznacz-wykonane.php. Zacznij od <?php. Plik ma sam PHP, więc bez zamykającego ?>.
  2. Pierwsza instrukcja: session_start(); — strona korzysta z sesji (konwencja od Lekcji 14).
  3. Pod nią dołącz przez require_once __DIR__ . '/...'; wszystkie potrzebne pliki. Listę ułóż sama/sam, odpowiadając na pytania:
    • skąd funkcje bazy wezmą stałe BAZA_...?
    • strona dostanie z bazy obiekt Zadanie i obiekt Uzytkownik — co musi być dołączone, żeby te obiekty mogły powstać?
    • metody klasy Uzytkownik korzystają z funkcji z dwóch plików (napisane jest to w komentarzu na górze classes/Uzytkownik.php) — z których?
    • w którym pliku są wymagajLogowania() i ustawKomunikat()?
    • w którym są funkcje bazy?
  4. Pod dołączeniami wywołaj wymagajLogowania();.

Sprawdzenie. Zalogowana/zalogowany wpisz w pasku adresu http://localhost/taskquest/oznacz-wykonane.php. Oczekiwane: pusta biała strona, bez błędów, bo plik jeszcze nic nie robi. Po wylogowaniu ten sam adres przekierowuje na logowanie.

HINT

Plików jest siedem. Porównaj z listą require_once w index.php — ta strona potrzebuje prawie tych samych, ale nie wyświetla listy zadań ani nie czyści tytułów.

ROZWIĄZANIE
<?php

session_start();

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

wymagajLogowania();

6b. Tylko POST

Strona zmienia dane, więc reaguje wyłącznie na POST (reguła z Lekcji 13). Każde inne wejście, np. wpisanie adresu, odsyła na listę bez żadnej zmiany.

Otwórz wyloguj.php. Na jego początku jest warunek, który sprawdza metodę żądania i przekierowuje. Przepisz ten sam warunek do oznacz-wykonane.php, pod wymagajLogowania();. Sprawdź, czy przekierowuje na index.php.

Sprawdzenie. Ten sam adres co w 6a (zalogowana/zalogowany). Oczekiwane: zamiast pustej strony — przekierowanie na listę zadań.

6c. Numer zadania z formularza

Przycisk „Zrobione!” (krok 7) wyśle w formularzu pole id — numer klikniętego zadania. Pod warunkiem z 6b:

  1. Odczytaj $_POST['id'] do zmiennej $zadanieIdTekst. Zrób to tak samo jak odczyt pól w dodaj-zadanie.php, czyli z ?? '', bo pola może w ogóle nie być.
  2. Sprawdź, czy tekst składa się z samych cyfr. Użyj tego samego wyrażenia regularnego, którym walidujNoweZadanie() sprawdza XP. Jeśli nie pasuje, zrób przekierowanie na index.php i exit, bez komunikatu. Zwykły formularz zawsze wysyła liczbę, więc coś innego oznacza, że ktoś przy nim majstrował.
  3. Dopiero po sprawdzeniu zamień tekst na liczbę i zapisz w $zadanieId.
  4. Numer zalogowanego gracza zapisz w $uzytkownikId (z $_SESSION['uzytkownik_id']). Nie potrzebujesz tu ??, bo wyżej jest wymagajLogowania().

Działanie tego fragmentu sprawdzisz w Teście 6 (F12), gdy będzie już przycisk.

ROZWIĄZANIE
$zadanieIdTekst = $_POST['id'] ?? '';

if (!preg_match('/^\d+$/', $zadanieIdTekst)) {
    header('Location: index.php');
    exit;
}

$zadanieId = (int) $zadanieIdTekst;
$uzytkownikId = $_SESSION['uzytkownik_id'];

6d. SAMODZIELNIE: trzy możliwe wyniki kliknięcia

Pod kodem z 6c otwórz blok try. W nim połącz się z bazą (polaczZBaza()) i obsłuż trzy sytuacje:

SytuacjaJak ją rozpoznaszCo robi strona
zadania nie ma albo jest cudzewczytajZadanieUzytkownika() zwraca nullkomunikat Nie znaleziono takiego zadania.
zadanie jest już wykonaneoznaczZadanieJakoWykonane() zwraca falsekomunikat To zadanie jest już wykonane.
udało sięoznaczZadanieJakoWykonane() zwraca truewczytuje gracza z bazy, aktualizuje jego streak (5b), zapisuje postęp (5c), komunikat Zadanie wykonane! +N XP, gdzie N to nagroda za zadanie

Komunikaty ustawiasz funkcją ustawKomunikat() (Lekcja 14).

try wymaga catch. Napiszesz go w 6e — zrób to od razu, zanim uruchomisz stronę. Pod całym try/catch dopisz przekierowanie na index.php i exit. Wykona się ono w każdej z trzech sytuacji, bo komunikat czeka w sesji.

HINT
  • Kolejność ma znaczenie: najpierw odczyt zadania (wiersz 1 tabeli), potem zmiana (wiersze 2 i 3). Wystarczy jedno if / elseif / else.
  • W wierszu 2 wywołanie funkcji możesz wpisać bezpośrednio w warunek elseif — funkcja wykona UPDATE i od razu zwróci true albo false.
  • Nagrodę za zadanie weźmiesz z obiektu zadania. Nowy streak weźmiesz z obiektu gracza po wywołaniu aktualizujStreak().
POMOC
try {
    $polaczenie = polaczZBaza();
    $zadanie = /* zadanie tego gracza albo null */;

    if (/* nie ma zadania */) {
        // komunikat
    } elseif (/* oznaczenie się NIE udało */) {
        // komunikat
    } else {
        // 1. wczytaj gracza
        // 2. zaktualizuj jego streak
        // 3. zapisz postęp: id gracza, nagroda za zadanie, nowy streak
        // 4. komunikat z liczbą XP
    }
}
ROZWIĄZANIE
try {
    $polaczenie = polaczZBaza();
    $zadanie = wczytajZadanieUzytkownika($polaczenie, $zadanieId, $uzytkownikId);

    if ($zadanie === null) {
        ustawKomunikat('Nie znaleziono takiego zadania.');
    } elseif (!oznaczZadanieJakoWykonane($polaczenie, $zadanieId, $uzytkownikId)) {
        ustawKomunikat('To zadanie jest już wykonane.');
    } else {
        $uzytkownik = wczytajUzytkownika($polaczenie, $uzytkownikId);
        $uzytkownik->aktualizujStreak();
        zapiszPostepUzytkownika($polaczenie, $uzytkownikId, $zadanie->pobierzXp(), $uzytkownik->pobierzStreak());
        ustawKomunikat('Zadanie wykonane! +' . $zadanie->pobierzXp() . ' XP');
    }
}

Dlaczego tak:

  • Najpierw SELECT, potem UPDATE. Potrzebujemy nagrody XP i chcemy odróżnić „nie ma / cudze” od „już wykonane”, żeby pokazać właściwy komunikat.
  • O przyznaniu XP decyduje wynik UPDATE, a nie $zadanie->czyWykonane(). To wzór z 3.3: odczyt mógł być sprzed ułamka sekundy, a UPDATE z IS NULL rozstrzyga ostatecznie.
  • Gracza wczytujemy dopiero, gdy XP się należy. Nie ma sensu pytać bazy, jeśli nic nie zmieniamy. wczytajUzytkownika() nie zwróci tu null: zadanie należy do tego gracza, a klucz obcy nie pozwala istnieć zadaniu bez właściciela.
  • Komunikat „+N XP” zawiera liczbę z bazy, a nie tekst od użytkownika — zasada z Lekcji 14 obowiązuje.
  • Wszystkie trzy komunikaty są zielone (.sukces), bo komunikat jednorazowy z Lekcji 14 ma jeden styl. „To zadanie jest już wykonane.” na zielono to kompromis, na który się godzimy.

6e. Awaria bazy

Gdy baza nie działa, strona nie może po cichu przekierować, bo gracz myślałby, że kliknięcie zadziałało. Pokażemy ramkę błędu, tak jak robi to index.php od Lekcji 18.

  1. Za klamrą zamykającą try dopisz catch (Exception $wyjatek) { }.
  2. Otwórz index.php i znajdź blok z komentarzem // --- awaria bazy .... Skopiuj jego wnętrze (od $nazwaStrony = 'TaskQuest'; do exit;) do środka catch.
  3. Linię z echo i ramką błędu zamień tak, żeby ramka miała tekst Nie udało się zapisać zmiany: i komunikat wyjątku. W index.php komunikat był w zmiennej $bladBazy. Tu weź go wprost z $wyjatek->getMessage() i doklej kropką.
  4. Pod linią z ramką, przed dołączeniem stopki, dodaj link powrotu: <p><a href="index.php">← Wróć do listy zadań</a></p>. Gracz trafił na stronę, która zwykle nic nie pokazuje, więc potrzebuje drogi powrotu.

Sprawdzisz to w Teście 8.

ROZWIĄZANIE
} catch (Exception $wyjatek) {
    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo '<p class="blad">Nie udało się zapisać zmiany: ' . $wyjatek->getMessage() . '</p>';
    echo '<p><a href="index.php">← Wróć do listy zadań</a></p>';
    require_once __DIR__ . '/includes/footer.php';
    exit;
}

header('Location: index.php');
exit;

Przycisku „Zrobione!” jeszcze nie ma, więc pełne działanie strony sprawdzisz po kroku 7.

Krok 7 — index.php: przycisk „Zrobione!” i koniec symulacji

7a. Usuwamy symulację streaka

W bloku wywołań usuń dwie linie:

$zadanieWykonane = $liczbaWykonanych > 0;
$uzytkownik->aktualizujStreak($zadanieWykonane);

Streak jest teraz liczony i zapisywany w oznacz-wykonane.php. Strona pokazuje wartość z bazy ($uzytkownik->pobierzStreak() w prezentacji — bez zmian). Wyszukaj w pliku $zadanieWykonane — nie może już występować.

Uwaga: gdybyś zostawił(a) drugą linię, PHP nie zgłosi błędu. Nadmiarowy argument przy wywołaniu metody jest po cichu pomijany. Metoda przeliczyłaby streak przy każdym wyświetleniu — tak jakby gracz przy każdym odświeżeniu wykonywał zadanie (pytanie kontrolne 10).

7b. SAMODZIELNIE: przycisk przy zadaniu

W index.php znajdź pętlę foreach ($zadaniaDoWyswietlenia as $zadanie) {. Każde zadanie jest w niej wypisywane przez echo jako <div class="$klasa"> ... </div>. Ostatnia linia pętli to echo "</div>";.

Uwaga — nie ruszaj linii z terminem. Pod echo "<p>Termin: " ... jest warunek if (!$zadanie->czyWykonane()) z komunikatem terminu, a pod nim echo "</p>";. Nie wstawiaj przycisku w ten warunek. Po pierwsze, zniknąłby komunikat terminu. Po drugie, formularz znalazłby się wewnątrz akapitu <p>, a w HTML formularz nie może stać w akapicie. Przeglądarka zamknie wtedy <p> przed <form> na własną rękę, a to końcowe </p> zostanie bez pary.

Co zrobić:

1. Nad linią echo "</div>"; (ostatnia w pętli) dopisz warunek, który jest spełniony tylko dla niewykonanych zadań. Jest to ten sam warunek, który stoi przy komunikacie terminu.

2. W środku warunku wypisz przez echo formularz. Dla zadania o id = 3 w źródle strony ma się pojawić dokładnie taki HTML:

<form class="akcja" method="post" action="oznacz-wykonane.php">    <input type="hidden" name="id" value="3">
    <button type="submit">Zrobione!</button>
</form>

Liczbę w value weź z obiektu zadania i doklej kropką. Pozostałe linie to stały tekst.

  • type="hidden" to pole, którego nie widać na stronie, a które formularz wysyła jak każde inne. Przenosi id, czyli informację, którego zadania dotyczy kliknięcie.
  • Dlaczego formularz, a nie link oznacz-wykonane.php?id=3? Link to GET, a GET nie zmienia danych (Lekcja 13). Przeglądarki i różne narzędzia potrafią same „odwiedzać” linki, np. przy wstępnym ładowaniu stron. Każde takie odwiedzenie wykonałoby zadanie.

Sprawdzenie. Odśwież stronę i otwórz źródło (Ctrl+U):

  • każde niewykonane zadanie ma swój formularz z innym value, równym jego id z phpMyAdmin,
  • wykonane zadania nie mają formularza,
  • komunikaty terminu („Zostało N dni” itd.) nadal są widoczne.
HINT

Tekst z cudzysłowami podwójnymi w środku najwygodniej pisać w apostrofach: echo '<form class="akcja" ...>';. Doklejanie liczby: '... value="' . wartosc . '">'.

ROZWIĄZANIE
            if (!$zadanie->czyWykonane()) {
                echo '<form class="akcja" method="post" action="oznacz-wykonane.php">';
                echo '<input type="hidden" name="id" value="' . $zadanie->pobierzId() . '">';
                echo '<button type="submit">Zrobione!</button>';
                echo '</form>';
            }

            echo "</div>";

pobierzId() zwraca int, więc w atrybucie value jest bezpieczny.

7c. Styl

W includes/header.php, w <style> pod regułą .zadanie.po-terminie, dopisz:

form.akcja { display: inline; }

Formularze są domyślnie blokami (każdy od nowej linii). Przycisk przy zadaniu ma stać w linii. Klasa akcja nie zmienia wyglądu formularzy „Dodaj zadanie” i „Logowanie”.

7d. Sprawdzenie

Zaloguj się jako Ola (250 XP, streak 0). Kliknij „Zrobione!” przy „Zaprojektować bazę TaskQuest”. Oczekiwane: komunikat „Zadanie wykonane! +30 XP”, 280 XP, poziom 3, streak 1, zadanie oznaczone [X] i bez przycisku. W phpMyAdmin: data_wykonania i data_ostatniej_aktywnosci Oli to dzisiejsza data.

Krok 8 — diagnostyka.php i stopka

  1. W diagnostyka.php, w $wymaganePliki: usuń 'includes/funkcje_plikow.php' i 'data/zadania.csv', dopisz 'oznacz-wykonane.php' (razem 18 ścieżek; w sekcji 6 dojdzie jeszcze jedna).
  2. Usuń linię $plikiCsv = listaPlikowWKatalogu('data', 'csv'); i całą sekcję <h2>Pliki danych</h2> z jej zawartością. Katalogu data/ już nie ma. Funkcja nie zgłosiłaby błędu (dla nieistniejącego katalogu zwraca []), ale sekcja o nieistniejących danych tylko by myliła.
  3. includes/footer.php: TaskQuest 1.9.

Diagnostyka nadal nie łączy się z bazą i nie dołącza config.php (zasada z Lekcji 10).

6. Kolejne zadanie — usuwanie zadania

Kama dodała zadanie z literówką w terminie i chce się go pozbyć. Do tej pory mogła tylko poprosić kogoś z dostępem do phpMyAdmin.

Przy każdym zadaniu na liście (także wykonanym) ma się pojawić przycisk „Usuń”.

Wymagania:

  1. W funkcje_bazy.php (pod zapiszPostepUzytkownika()) funkcja usunZadanie(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool. Usuwa zadanie tylko wtedy, gdy należy do użytkownika. Zwraca true, gdy wiersz został usunięty.
  2. Nowa strona usun-zadanie.php, zbudowana jak oznacz-wykonane.php: tylko dla zalogowanych, tylko POST, id sprawdzone regexem, na końcu przekierowanie na index.php.
  3. Komunikaty: Zadanie zostało usunięte. albo Nie znaleziono takiego zadania.. Awaria bazy — ramka .blad „Nie udało się usunąć zadania: …” z linkiem powrotu.
  4. W index.php, w pętli listy, przycisk „Usuń” w formularzu z klasą akcja, zbudowany tak jak „Zrobione!” w 7b. Wstaw go pod zamykającą klamrą warunku z formularzem „Zrobione!”, a nad echo "</div>"; — poza warunkiem, żeby był widoczny przy każdym zadaniu.
  5. usun-zadanie.php w $wymaganePliki w diagnostyka.php (razem 19).

Nie dodajemy okna „Czy na pewno?”. To zadanie końcowe D.

HINT

usun-zadanie.php jest krótszy niż oznacz-wykonane.php. Zastanów się, czy przed DELETE potrzebujesz SELECT. W oznacz-wykonane.php był potrzebny z dwóch powodów (pierwszy punkt „Dlaczego tak” w kroku 6d). Czy któryś z nich dotyczy usuwania? Których plików strona nie musi dołączać, skoro nie tworzy obiektów?

POMOC
DELETE FROM zadania WHERE id = ? AND uzytkownik_id = ?

Wynik funkcji — tak samo jak w oznaczZadanieJakoWykonane(). Na stronie wystarczy if/else na wyniku funkcji.

ROZWIĄZANIE

includes/funkcje_bazy.php:

function usunZadanie(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
{
    $zapytanie = $polaczenie->prepare('DELETE FROM zadania WHERE id = ? AND uzytkownik_id = ?');
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
}

usun-zadanie.php:

<?php

session_start();

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

wymagajLogowania();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}

$zadanieIdTekst = $_POST['id'] ?? '';

if (!preg_match('/^\d+$/', $zadanieIdTekst)) {
    header('Location: index.php');
    exit;
}

$zadanieId = (int) $zadanieIdTekst;

try {
    $polaczenie = polaczZBaza();

    if (usunZadanie($polaczenie, $zadanieId, $_SESSION['uzytkownik_id'])) {
        ustawKomunikat('Zadanie zostało usunięte.');
    } else {
        ustawKomunikat('Nie znaleziono takiego zadania.');
    }
} catch (Exception $wyjatek) {
    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo '<p class="blad">Nie udało się usunąć zadania: ' . $wyjatek->getMessage() . '</p>';
    echo '<p><a href="index.php">← Wróć do listy zadań</a></p>';
    require_once __DIR__ . '/includes/footer.php';
    exit;
}

header('Location: index.php');
exit;

Bez SELECT: usuwanie nie potrzebuje XP zadania, a „nie ma” i „cudze” dają ten sam komunikat. affected_rows mówi wszystko. Bez klas: strona nie tworzy obiektów.

index.php — koniec pętli listy:

            if (!$zadanie->czyWykonane()) {
                echo '<form class="akcja" method="post" action="oznacz-wykonane.php">';
                echo '<input type="hidden" name="id" value="' . $zadanie->pobierzId() . '">';
                echo '<button type="submit">Zrobione!</button>';
                echo '</form>';
            }

            echo '<form class="akcja" method="post" action="usun-zadanie.php">';
            echo '<input type="hidden" name="id" value="' . $zadanie->pobierzId() . '">';
            echo '<button type="submit">Usuń</button>';
            echo '</form>';

            echo "</div>";

Pytanie do przemyślenia: Kama wykonała zadanie za 20 XP, a potem je usunęła. Ile XP ma teraz? Czy tak powinno być? (Pytanie kontrolne 9.)

7. Testy

Gdzie: przeglądarka i phpMyAdmin. Przed każdym testem zapisz przewidywany wynik. Po testach, które zmieniają dane, uruchom ponownie sql/taskquest.sql.

Test 1 — dodawanie zadania

Zaloguj się jako Kama. W phpMyAdmin sprawdź liczbę zadań Kamy: SELECT COUNT(*) FROM zadania WHERE uzytkownik_id = 1;. Dodaj przez formularz zadanie: tytuł Powtórzyć INSERT , opis pusty, termin za tydzień, 10 XP.

Co sprawdzaszOczekiwane
strona po wysłaniuindex.php, komunikat „Zadanie zostało dodane.”, zadanie na liście (tytuł wyświetlony bez nadmiarowych spacji), przycisk „Zrobione!”
COUNT(*)o 1 więcej
nowy wiersz w phpMyAdminuzytkownik_id = 1, data_wykonania = NULL, xp = 10, tytuł bez nadmiarowych spacji (czyści go wyczyscTytul() przy odczycie z formularza)
odświeżenie (F5) po dodaniubrak drugiego wiersza, komunikat znika (POST → przekierowanie → GET)
Ola po zalogowaniunie widzi tego zadania

Test 2 — walidacja nadal działa (regresja)

Wyślij formularz z: pustym tytułem; XP 0; XP 101; terminem 2026-02-31. Oczekiwane: ramka „Popraw formularz:” z właściwym komunikatem, pola wypełnione (formularz zachowuje wpisane dane), a COUNT(*) bez zmian — do bazy nie trafił żaden wiersz.

Test 3 — „Zrobione!”, XP i streak

Zaloguj się jako Kama. Przygotuj dane w phpMyAdmin (przed każdym wierszem tabeli):

UPDATE zadania SET data_wykonania = NULL WHERE uzytkownik_id = 1;
UPDATE uzytkownicy SET xp = 99, streak = 5, data_ostatniej_aktywnosci = CURDATE() - INTERVAL 1 DAY WHERE id = 1;

CURDATE() - INTERVAL 1 DAY to „dzisiaj minus jeden dzień”, czyli wczoraj. W kolejnych wierszach zmieniaj tylko datę. Kliknij „Zrobione!” przy dowolnym zadaniu za N XP.

data_ostatniej_aktywnosci przedXP postreak podata aktywności po
CURDATE() - INTERVAL 1 DAY99 + N6dziś
CURDATE()99 + N5dziś
CURDATE() - INTERVAL 3 DAY99 + N1dziś
NULL99 + N1dziś

Kliknij „Zrobione!” przy drugim zadaniu (bez przygotowania danych). Streak się nie zmienia (dziś już była aktywność), XP rośnie o nagrodę drugiego zadania.

Jeśli wyniki w pierwszym wierszu się nie zgadzają tuż przed północą albo tuż po niej, sprawdź, czy PHP i MySQL mają tę samą strefę czasową. aktualizujStreak() liczy „dziś” według PHP, a CURDATE() według MySQL.

Test 4 — XP tylko raz

  1. Zaloguj się jako Ola. Otwórz index.php w dwóch kartach.
  2. W pierwszej kliknij „Zrobione!” przy jej zadaniu — +30 XP, 280.
  3. W drugiej karcie (przycisk nadal widoczny, bo karta nie była odświeżana) kliknij „Zrobione!” przy tym samym zadaniu.

Oczekiwane: komunikat „To zadanie jest już wykonane.”, nadal 280 XP. Zadanie w bazie ma jedną datę wykonania.

Test 5 — cudze zadanie

  1. Odczytaj w phpMyAdmin id dowolnego zadania Kamy.
  2. Zaloguj się jako Ola. Na stronie otwórz narzędzia przeglądarki (F12) → zakładka z kodem strony (Elementy / Inspektor). Znajdź <input type="hidden" name="id" ...> przy zadaniu Oli i zmień value na id zadania Kamy.
  3. Kliknij „Zrobione!”.

Oczekiwane: „Nie znaleziono takiego zadania.”, XP Oli bez zmian, zadanie Kamy w bazie nadal z data_wykonania = NULL. Powtórz to samo z przyciskiem „Usuń” — zadanie Kamy nadal istnieje.

Test 6 — nietypowe żądania

Co robiszOczekiwane
wpisujesz w pasek adresu oznacz-wykonane.php (GET)powrót na index.php, nic się nie zmienia
wpisujesz usun-zadanie.php (GET)j.w.
w F12 zmieniasz value pola id na abc, -1, 2.5 albo pusty tekst i klikaszpowrót na listę bez komunikatu, nic się nie zmienia
wylogowujesz się, potem wpisujesz oznacz-wykonane.phpprzekierowanie na zaloguj.php (wymagajLogowania())
id nieistniejącego zadania (np. 9999)„Nie znaleziono takiego zadania.”

Test 7 — usuwanie

Zaloguj się jako Kama. Kliknij „Usuń” przy zadaniu niewykonanym. Oczekiwane: „Zadanie zostało usunięte.”, zadanie zniknęło z listy i z bazy, liczniki zmniejszyły się o 1. Wykonaj inne zadanie i usuń je. XP nie maleje.

Test 8 — baza nie działa

  1. Zaloguj się, otwórz dodaj-zadanie.php i wypełnij formularz. Zatrzymaj w XAMPP MySQL i wyślij. Oczekiwane: formularz z ramką „Nie udało się dodać zadania: Nie udało się połączyć z bazą danych.”, wpisane dane zostają w polach.
  2. Uruchom MySQL, wejdź na listę, zatrzymaj MySQL i kliknij „Zrobione!”. Oczekiwane: strona z ramką „Nie udało się zapisać zmiany: Nie udało się połączyć z bazą danych.” i linkiem powrotu.
  3. To samo z „Usuń”: „Nie udało się usunąć zadania: …”. Uruchom MySQL.

Test 9 — ranking się zmienia

Po świeżym taskquest.sql: Ola 250, Kama 99. Zaloguj się jako Kama. Wykonuj zadania, aż Kama ma więcej XP niż Ola (w razie potrzeby dodaj przez formularz zadanie za 100 XP i je wykonaj). Oczekiwane: Kama na 1. miejscu rankingu — bez jednej zmiany w pobierzRanking().

Test 10 — diagnostyka

diagnostyka.php → 19 × [OK] (w tym oznacz-wykonane.php i usun-zadanie.php, bez funkcje_plikow.php i data/zadania.csv), brak sekcji „Pliki danych”.

8. Obowiązkowe eksperymenty

Gdzie: eksperyment 1 w phpMyAdmin, 4 w brudnopisie, pozostałe w projekcie. Po każdym przywróć zmieniony kod i uruchom ponownie sql/taskquest.sql.

Eksperyment 1: UPDATE i DELETE bez WHERE

W phpMyAdmin:

UPDATE zadania SET data_wykonania = CURDATE();

Przewidź liczbę zmienionych wierszy. Zaloguj się jako Kama, potem jako Ola. Wszystkie zadania wszystkich graczy są wykonane — i nikt nie dostał za nie XP. Potem:

DELETE FROM zadania;

Obie listy są puste. Użytkownicy zostali, bo usunęliśmy wiersze z zadania, nie z uzytkownicy. (Odwrotnie ON DELETE CASCADE z Lekcji 17 usunąłby także zadania.) Wniosek: w PHP każde UPDATE i DELETE piszemy od razu z WHERE, a gotowe zapytanie czytamy jeszcze raz przed uruchomieniem. Uruchom ponownie taskquest.sql.

Eksperyment 2: bez warunku stanu

W oznaczZadanieJakoWykonane() usuń z zapytania AND data_wykonania IS NULL. Zaloguj się jako Ola i powtórz Test 4 (dwie karty).

Przewidź wynik. Oczekiwane — niespodzianka: druga karta nadal pokazuje „To zadanie jest już wykonane.”. Dlaczego? Drugi UPDATE wpisuje tę samą datę, która już jest, więc według 3.1 nic się nie zmienia i affected_rows = 0. Warunek wygląda na zbędny.

Teraz zasymuluj, że zadanie zostało wykonane wczoraj, a karta z przyciskiem była otwarta od wczoraj:

UPDATE zadania SET data_wykonania = CURDATE() - INTERVAL 1 DAY WHERE uzytkownik_id = 2;

Kliknij „Zrobione!” w drugiej karcie (tej nieodświeżonej). Oczekiwane: „Zadanie wykonane! +30 XP” — Ola dostała XP drugi raz za to samo zadanie. Data zmieniła się z wczorajszej na dzisiejszą, więc wiersz „się zmienił”.

Wniosek: bez warunku stanu przyznanie XP zależy od przypadku — od tego, czy nowa wartość akurat różni się od starej. Z AND data_wykonania IS NULL odpowiedź jest zawsze jednoznaczna: wykonane zadanie nie pasuje do WHERE. Przywróć warunek.

Eksperyment 3: bez warunku właściciela

W oznaczZadanieJakoWykonane() i w wczytajZadanieUzytkownika() usuń AND uzytkownik_id = ?, a w bind_param() zostaw jeden parametr ('i', $zadanieId). Powtórz Test 5.

Oczekiwane: Ola wykonuje zadanie Kamy i Ola dostaje za nie XP. Prepared statement działa bez zarzutu — nie ma tu SQL injection. Zapytanie robi dokładnie to, o co je poproszono. Błąd polega na tym, że nie zapytaliśmy, czy wolno. Przywróć oba warunki.

Eksperyment 4: co liczy affected_rows

W brudnopisie:

$xp = 99;
$uzytkownikId = 1;

$zapytanie = $polaczenie->prepare('UPDATE uzytkownicy SET xp = ? WHERE id = ?');
$zapytanie->bind_param('ii', $xp, $uzytkownikId);

$zapytanie->execute();
var_dump($zapytanie->affected_rows);

$zapytanie->execute();
var_dump($zapytanie->affected_rows);

Przewidź oba wyniki. Po świeżym taskquest.sql Kama ma 99 XP. Oczekiwane: int(0), int(0). Zmień $xp na 100: int(1), int(0). Ten sam przygotowany $zapytanie można wykonać wiele razy — tu drugi raz nic już nie zmienia. Wniosek: affected_rows = 0 nie znaczy „nie ma takiego wiersza”. Znaczy „nic się nie zmieniło”.

Eksperyment 5: połowa zmiany

W zapiszPostepUzytkownika() zrób literówkę w nazwie kolumny: xp = xp + ? → xpp = xp + ?. Zaloguj się jako Ola i kliknij „Zrobione!”.

Oczekiwane: ramka „Nie udało się zapisać zmiany: Unknown column 'xpp’ …”. Wróć na listę. Zadanie jest oznaczone jako wykonane, a XP nadal 250. Kliknąć drugi raz się nie da — przycisku nie ma, a nawet gdyby był, oznaczZadanieJakoWykonane() zwróci false. 30 XP przepadło na zawsze.

Dlaczego? Pierwsze zapytanie (UPDATE zadania) się udało i jego zmiana została w bazie. Drugie (UPDATE uzytkownicy) rzuciło wyjątek. Baza nie wie, że te dwie zmiany „należą do siebie”. Dane są niespójne. Tu literówka jest sztuczna, ale w prawdziwej aplikacji drugie zapytanie może przerwać zanik połączenia, restart serwera czy przekroczenie czasu. Rozwiązanie — transakcja: „wykonaj obie zmiany albo żadną” (zadanie końcowe C). Przywróć xp.

Eksperyment 6: stary streak na stronie

W index.php dopisz tymczasowo w bloku wywołań (przed prezentacją):

$uzytkownik->aktualizujStreak();

Ustaw w phpMyAdmin Kamie streak = 5 i data_ostatniej_aktywnosci = CURDATE() - INTERVAL 1 DAY. Odśwież stronę kilka razy.

Oczekiwane: strona pokazuje 6, choć w bazie jest 5 i Kama nic dziś nie zrobiła. Metoda „udaje” wykonanie zadania przy każdym wyświetleniu (tak działała symulacja do Lekcji 18). Wynik nie trafia do bazy, więc każde odświeżenie znowu pokazuje 6. Usuń dopisaną linię.

9. ROZWIĄZANIE

Poniżej wszystkie pliki, które zmieniły się w Lekcji 19. Pliki niewymienione są identyczne jak po Lekcji 18 (wersja w 18_TaskQuest.md, sekcja 9, a tam, gdzie L18 odsyła dalej — w 15_TaskQuest.md i 17_TaskQuest.md).

ROZWIĄZANIE

Usunięte

includes/funkcje_plikow.php          (wczytajZadania(), zapiszZadania(), sprawdzWierszZadania())
data/zadania.csv i katalog data/
config/config.php: define('PLIK_ZADAN', ...);
includes/funkcje_zadan.php: function nastepneIdZadania(...)

includes/funkcje_bazy.php (cały plik)

<?php

// Funkcje TaskQuest korzystające z bazy danych (Lekcje 18–19).
// 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);
}

function dodajZadanie(
    mysqli $polaczenie,
    int $uzytkownikId,
    string $tytul,
    string $opis,
    string $termin,
    int $xp
): int {
    $zapytanie = $polaczenie->prepare(
        'INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp) VALUES (?, ?, ?, ?, ?)'
    );
    $zapytanie->bind_param('isssi', $uzytkownikId, $tytul, $opis, $termin, $xp);
    $zapytanie->execute();

    return $zapytanie->insert_id;
}

function wczytajZadanieUzytkownika(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): ?Zadanie
{
    $zapytanie = $polaczenie->prepare(
        'SELECT id, tytul, opis, termin, data_wykonania, xp FROM zadania WHERE id = ? AND uzytkownik_id = ?'
    );
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();
    $wiersz = $zapytanie->get_result()->fetch_assoc();

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

    return new Zadanie(
        id: $wiersz['id'],
        tytul: $wiersz['tytul'],
        opis: $wiersz['opis'],
        termin: $wiersz['termin'],
        wykonane: $wiersz['data_wykonania'] !== null,
        xp: $wiersz['xp'],
    );
}

function oznaczZadanieJakoWykonane(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
{
    $zapytanie = $polaczenie->prepare(
        'UPDATE zadania SET data_wykonania = CURDATE() WHERE id = ? AND uzytkownik_id = ? AND data_wykonania IS NULL'
    );
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
}

function zapiszPostepUzytkownika(mysqli $polaczenie, int $uzytkownikId, int $zdobyteXp, int $streak): void
{
    $zapytanie = $polaczenie->prepare(
        'UPDATE uzytkownicy SET xp = xp + ?, streak = ?, data_ostatniej_aktywnosci = CURDATE() WHERE id = ?'
    );
    $zapytanie->bind_param('iii', $zdobyteXp, $streak, $uzytkownikId);
    $zapytanie->execute();
}

function usunZadanie(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
{
    $zapytanie = $polaczenie->prepare('DELETE FROM zadania WHERE id = ? AND uzytkownik_id = ?');
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
}

classes/Uzytkownik.php — zmiana

Metoda aktualizujStreak(bool $zadanieWykonane) zastąpiona wersją bez parametru (kod w kroku 5b). Reszta klasy bez zmian.

dodaj-zadanie.php — zmiany

  1. Usunięte require_once plików classes/Zadanie.php i includes/funkcje_plikow.php; pod includes/funkcje_sesji.php dopisane require_once __DIR__ . '/includes/funkcje_bazy.php';.
  2. Zawartość warunku „walidacja bez błędów” zastąpiona blokiem try z dodajZadanie() (kod w kroku 2b).
  3. Odczyt pól, walidacja i cała część HTML bez zmian.

oznacz-wykonane.php (nowy plik)

Cały plik (kroki 6a–6e razem):

<?php

session_start();

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

wymagajLogowania();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}

$zadanieIdTekst = $_POST['id'] ?? '';

if (!preg_match('/^\d+$/', $zadanieIdTekst)) {
    header('Location: index.php');
    exit;
}

$zadanieId = (int) $zadanieIdTekst;
$uzytkownikId = $_SESSION['uzytkownik_id'];

try {
    $polaczenie = polaczZBaza();
    $zadanie = wczytajZadanieUzytkownika($polaczenie, $zadanieId, $uzytkownikId);

    if ($zadanie === null) {
        ustawKomunikat('Nie znaleziono takiego zadania.');
    } elseif (!oznaczZadanieJakoWykonane($polaczenie, $zadanieId, $uzytkownikId)) {
        ustawKomunikat('To zadanie jest już wykonane.');
    } else {
        $uzytkownik = wczytajUzytkownika($polaczenie, $uzytkownikId);
        $uzytkownik->aktualizujStreak();
        zapiszPostepUzytkownika($polaczenie, $uzytkownikId, $zadanie->pobierzXp(), $uzytkownik->pobierzStreak());
        ustawKomunikat('Zadanie wykonane! +' . $zadanie->pobierzXp() . ' XP');
    }
} catch (Exception $wyjatek) {
    $nazwaStrony = 'TaskQuest';
    require_once __DIR__ . '/includes/header.php';
    echo '<p class="blad">Nie udało się zapisać zmiany: ' . $wyjatek->getMessage() . '</p>';
    echo '<p><a href="index.php">← Wróć do listy zadań</a></p>';
    require_once __DIR__ . '/includes/footer.php';
    exit;
}

header('Location: index.php');
exit;

usun-zadanie.php (nowy plik)

Cały plik — kod w sekcji 6, ROZWIĄZANIE.

index.php — zmiany

  1. Usunięte linie $zadanieWykonane = $liczbaWykonanych > 0; i $uzytkownik->aktualizujStreak($zadanieWykonane);.
  2. Pod <h2>Twoje zadania</h2> przywrócone <p><a href="dodaj-zadanie.php">+ Dodaj zadanie</a></p>.
  3. W pętli listy, nad ostatnią linią echo "</div>";, dopisane dwa formularze. Linie z terminem i komunikatem terminu bez zmian:
            if (!$zadanie->czyWykonane()) {
                echo '<form class="akcja" method="post" action="oznacz-wykonane.php">';
                echo '<input type="hidden" name="id" value="' . $zadanie->pobierzId() . '">';
                echo '<button type="submit">Zrobione!</button>';
                echo '</form>';
            }

            echo '<form class="akcja" method="post" action="usun-zadanie.php">';
            echo '<input type="hidden" name="id" value="' . $zadanie->pobierzId() . '">';
            echo '<button type="submit">Usuń</button>';
            echo '</form>';

Reszta pliku (blok danych z bazy, liczniki, filtr widoku, pasek XP, komunikat, ranking) bez zmian.

includes/header.php

W <style>, pod .zadanie.po-terminie: form.akcja { display: inline; }.

diagnostyka.php

W $wymaganePliki usunięte 'includes/funkcje_plikow.php' i 'data/zadania.csv', dopisane 'oznacz-wykonane.php' i 'usun-zadanie.php' (razem 19 ścieżek). Usunięta zmienna $plikiCsv i sekcja „Pliki danych”. Reszta bez zmian.

includes/footer.php

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

10. Co zmieniliśmy w TaskQuest?

Zostaje

klasa Zadanie (bez zmian), funkcje listy, grywalizacji, terminów (także aktualizujStreak()), tekstowe, sesji
walidujNoweZadanie(), formularz dodawania zachowujący wpisane dane, ramka $bladZapisu, komunikat jednorazowy, POST → przekierowanie → GET
logowanie jak po Lekcji 18 (login bez hasła), w sesji tylko uzytkownik_id
index.php: blok danych z bazy, obsługa awarii bazy, liczniki, filtr widoku + cookie, pasek XP, .po-terminie, ranking
baza taskquest i sql/taskquest.sql (bez zmian)
Uzytkownik::dodajXp(), Zadanie::oznaczJakoWykonane() — nadal nieużywane przez aplikację

Dochodzi

includes/funkcje_bazy.php:
    dodajZadanie(mysqli, int, string, string, string, int): int              — INSERT, zwraca insert_id
    wczytajZadanieUzytkownika(mysqli, int $zadanieId, int $uzytkownikId): ?Zadanie
    oznaczZadanieJakoWykonane(mysqli, int, int): bool                        — UPDATE z IS NULL
    zapiszPostepUzytkownika(mysqli, int $uzytkownikId, int $zdobyteXp, int $streak): void
    usunZadanie(mysqli, int, int): bool                                      — DELETE
oznacz-wykonane.php, usun-zadanie.php                                        — tylko POST, bez HTML
index.php: przyciski „Zrobione!” i „Usuń” (form.akcja, pole ukryte id), link „+ Dodaj zadanie”
header.php: form.akcja

Nowe narzędzia: SQL INSERT INTO ... (...) VALUES (...), UPDATE ... SET ... WHERE, SET kolumna = kolumna + ?, DELETE FROM ... WHERE, CURDATE(), CURDATE() - INTERVAL n DAY; affected_rows, insert_id; <input type="hidden">; warunek właściciela i warunek stanu w WHERE.

Zmieniamy

  • dodaj-zadanie.php: zapis do bazy zamiast CSV,
  • Uzytkownik::aktualizujStreak(): bez parametru, wywoływana tylko po wykonaniu zadania; null → seria 1,
  • index.php: streak pokazywany z bazy (bez przeliczania), link do formularza wraca, przyciski przy zadaniach,
  • diagnostyka.php: 19 plików, bez sekcji „Pliki danych”; stopka 1.9.

Usuwamy

CoDlaczego
$zadanieWykonane i wywołanie aktualizujStreak() w index.phpbyła to symulacja aktywności; teraz aktywność to kliknięcie „Zrobione!”, a streak jest zapisany w bazie
parametr bool $zadanieWykonane metody aktualizujStreak()metoda jest wywoływana tylko po wykonaniu zadania — parametr zawsze byłby true
data/zadania.csv, funkcje_plikow.php, PLIK_ZADANnic nie czyta ani nie zapisuje pliku; dane są w bazie od Lekcji 17
nastepneIdZadania()id nadaje AUTO_INCREMENT
classes/Zadanie.php w dodaj-zadanie.phpstrona nie tworzy obiektu — dane idą wprost do INSERT
sekcja „Pliki danych” w diagnostyka.phpnie ma katalogu data/

Co nowego potrafi TaskQuest po tej lekcji?

Po Lekcji 19 TaskQuest zapisuje zmiany w bazie. Gracz dodaje zadania przez formularz, oznacza je jako wykonane i dostaje za nie XP, a jego seria dni jest liczona w chwili wykonania zadania i zapisywana. Może też usuwać zadania. Aplikacja nie pozwala zmienić ani usunąć cudzego zadania i nie przyznaje XP dwa razy za to samo zadanie. Ranking zmienia się sam, gdy gracze zdobywają XP. Plik CSV zniknął — jedynym miejscem danych jest baza.

11. Zadania końcowe — rozbuduj TaskQuest samodzielnie

Nie są częścią wersji referencyjnej.

A. Edycja zadania. Nowa strona edytuj-zadanie.php?id=N. GET: id z adresu sprawdzone regexem, zadanie wczytane przez wczytajZadanieUzytkownika() (cudze albo nieistniejące → komunikat i powrót na listę), formularz jak w dodaj-zadanie.php wypełniony danymi zadania. POST: te same pola + ukryte id, walidacja przez walidujNoweZadanie(), zapis funkcją edytujZadanie(mysqli $polaczenie, int $zadanieId, int $uzytkownikId, string $tytul, string $opis, string $termin, int $xp): bool (UPDATE ... SET tytul = ?, opis = ?, termin = ?, xp = ? WHERE id = ? AND uzytkownik_id = ?). Przy zadaniu link „Edytuj” (GET — tylko otwiera formularz, niczego nie zmienia). Pytania: co zwróci funkcja, gdy użytkownik zapisze formularz bez żadnej zmiany (Eksperyment 4)? Czy wtedy pokażesz „Nie znaleziono takiego zadania.”? Czy wykonane zadanie można edytować i zmienić mu XP — co to znaczy dla XP gracza?

B. Przerwana seria. Kama miała ostatnią aktywność tydzień temu, a strona nadal pokazuje „streak 5”. Seria jest przerwana, choć baza tego jeszcze nie wie (dowie się przy następnym „Zrobione!”). Dodaj do Uzytkownik metodę pobierzAktualnyStreak(): int: gdy data ostatniej aktywności to dziś albo wczoraj — streak z bazy, w przeciwnym razie (także null) — 0. Użyj jej w prezentacji zamiast pobierzStreak(). Nie zapisuj niczego do bazy. Przetestuj daty: dziś, wczoraj, przedwczoraj, NULL. Dlaczego aktualizujStreak() nie może używać tej metody?

C. Transakcja. Napraw problem z Eksperymentu 5. W oznacz-wykonane.php, zaraz po polaczZBaza(), wywołaj $polaczenie->begin_transaction();. Po zapiszPostepUzytkownika() — $polaczenie->commit();. Do czasu commit() zmiany nie są ostateczne. W catch, jeśli połączenie istnieje (isset($polaczenie)), wywołaj $polaczenie->rollback(); — baza cofnie wszystko od begin_transaction(). Powtórz Eksperyment 5: po błędzie zadanie ma być niewykonane, XP bez zmian. Pytania: dlaczego transakcja działa w naszych tabelach (wskazówka: ENGINE=InnoDB)? Po co warunek isset($polaczenie)? Czy transakcja zastępuje warunek data_wykonania IS NULL?

D. Potwierdzenie usuwania. Przycisk „Usuń” prowadzi (GET, usun-zadanie.php?id=N) do strony z pytaniem „Czy na pewno usunąć zadanie „…”?” i dwoma przyciskami: „Tak, usuń” (formularz POST z ukrytym id) i „Anuluj” (link na listę). Dopiero POST usuwa. Na stronie potwierdzenia wyświetl tytuł zadania — skąd go weźmiesz i dlaczego musisz przy tym sprawdzić właściciela?

Sprzątanie

  1. Usuń brudnopis.php.
  2. Sprawdź, że przywrócone są: AND data_wykonania IS NULL i AND uzytkownik_id = ? w zapytaniach (Eksperymenty 2–3), xp w zapiszPostepUzytkownika() (Eksperyment 5), a w index.php nie ma wywołania aktualizujStreak() (Eksperyment 6).
  3. Uruchom ponownie sql/taskquest.sql.
  4. Zaloguj się jako Ola, kliknij „Zrobione!” — 280 XP, streak 1. Zaloguj się jako Kama, dodaj i usuń zadanie.

Do samodzielnego sprawdzenia

[ ] Nadal rozwijam ten sam projekt TaskQuest (nie zaczynam nowego).
[ ] Potrafię napisać INSERT z listą kolumn i wiem, które kolumny mogę pominąć.
[ ] Potrafię napisać UPDATE i DELETE z WHERE — i wiem, co się stanie bez WHERE.
[ ] Potrafię zwiększyć wartość w bazie względem obecnej (xp = xp + ?) i wiem, czemu to lepsze niż zapis gotowej liczby.
[ ] Wiem, co liczy affected_rows przy UPDATE i dlaczego 0 nie znaczy „nie ma wiersza”.
[ ] Wiem, do czego służy insert_id.
[ ] Potrafię wyjaśnić, dlaczego każde UPDATE/DELETE zadania ma warunek uzytkownik_id = ?.
[ ] Potrafię wyjaśnić, dlaczego XP przyznajemy na podstawie wyniku UPDATE z IS NULL, a nie wcześniejszego odczytu.
[ ] Wiem, dlaczego „Zrobione!” i „Usuń” to formularze POST, a nie linki.
[ ] Wiem, gdzie i kiedy liczony jest teraz streak.
[ ] Potrafię opisać, co się dzieje z danymi, gdy z dwóch zapytań uda się tylko pierwsze.
[ ] W projekcie nie ma już CSV ani funkcji plikowych.
[ ] Usunęłam/usunąłem brudnopis.

Pytania kontrolne

1. Tabela zadania po świeżym taskquest.sql. Podaj affected_rows każdego polecenia, wykonanych po kolei:

UPDATE zadania SET data_wykonania = CURDATE() WHERE uzytkownik_id = 2;
UPDATE zadania SET data_wykonania = CURDATE() WHERE uzytkownik_id = 2;
UPDATE zadania SET data_wykonania = CURDATE() WHERE uzytkownik_id = 2 AND data_wykonania IS NULL;
DELETE FROM zadania WHERE uzytkownik_id = 3;


2. Kolega uważa, że warunek AND data_wykonania IS NULL jest zbędny, bo oznacz-wykonane.php i tak najpierw wczytuje zadanie i mógłby sprawdzić $zadanie->czyWykonane(). Podaj dwie sytuacje, w których jego wersja przyzna XP dwa razy (Test 4, Eksperyment 2).

3. Koleżanka napisała UPDATE zadania SET data_wykonania = CURDATE() WHERE id = ?. Zapytanie działa, a prepared statement chroni przed SQL injection. Co może zrobić zalogowana Ola i jak? Czym ten błąd różni się od SQL injection?Znajdź trzy błędy:

$zapytanie = $polaczenie->prepare('DELETE FROM zadania WHERE id = ? AND uzytkownik_id = ?');
$zapytanie->bind_param('i', $zadanieId, (int) $_SESSION['uzytkownik_id']);
$zapytanie->execute();
$wynik = $zapytanie->get_result();


4. Dlaczego „Zrobione!” jest przyciskiem w formularzu POST, a nie linkiem oznacz-wykonane.php?id=3? Podaj dwa konkretne problemy wersji z linkiem.

5. Kama ma streak 4. Co pokaże strona po kliknięciu „Zrobione!”, jeśli jej data_ostatniej_aktywnosci to: (a) dziś, (b) wczoraj, (c) 10 dni temu, (d) NULL? Która linia kodu decyduje o przypadku (d)?

6. zapiszPostepUzytkownika() dodaje XP przez xp = xp + ?, a streak zapisuje jako gotową liczbę (streak = ?). Dlaczego te dwie kolumny potraktowaliśmy różnie? Czy przy streaku może wystąpić ten sam problem co przy XP?

7. W Eksperymencie 5 zadanie zostało oznaczone jako wykonane, a XP nie wpadło. Opisz stan bazy po błędzie. Czy odwrócenie kolejności zapytań (najpierw XP, potem zadanie) rozwiązuje problem? Co go rozwiązuje?

8. Kama wykonała zadanie za 20 XP, a potem je usunęła. XP zostało. Podaj argument za takim działaniem i argument przeciw. Jak zmieniłbyś/zmieniłabyś usun-zadanie.php, gdyby XP miało być odbierane?

9. Po zmianie metody na aktualizujStreak(): void w index.php została przez pomyłkę linia $uzytkownik->aktualizujStreak($zadanieWykonane); (a $zadanieWykonane nadal istnieje). Czy PHP zgłosi błąd? Co zobaczy Kama, która wczoraj wykonała zadanie, a dziś tylko otwiera stronę (Eksperyment 6)?

10. Baza odrzuci tytuł dłuższy niż 100 znaków (VARCHAR(100)). Po co więc walidujNoweZadanie() nadal to sprawdza? Podaj dwa powody.

PODSUMOWANIE LEKCJI

W skrócie

  • INSERT, UPDATE i DELETE nie zwracają wierszy. Znika więc ostatni krok znany z SELECT — po execute() nie ma get_result(). Efekt sprawdzamy przez affected_rows (PDO: rowCount()).
  • affected_rows przy UPDATE to liczba wierszy, które naprawdę się zmieniły, a nie tych, które pasowały do WHERE. Zapis tej samej wartości daje 0. Dlatego 0 nie znaczy „nie ma takiego wiersza”.
  • UPDATE i DELETE bez WHERE działają na całej tabeli. W PHP nie ma pytania „czy na pewno?” i nie ma cofnięcia.
  • WHERE pełni rolę bezpiecznika i ma zwykle dwa rodzaje warunków:
    • właściciela (AND uzytkownik_id = ?) — żeby nie dało się zmienić cudzego rekordu,
    • stanu (AND data_wykonania IS NULL) — żeby ta sama operacja nie zadziałała drugi raz.
  • Prepared statement chroni przed SQL injection, ale nie przed brakiem sprawdzenia uprawnień. To dwa różne błędy: pierwszy dotyczy sklejania SQL, drugi — zaufania danym z formularza.
  • O tym, czy nagroda (XP) się należy, decyduje wynik UPDATE z warunkiem stanu, a nie wcześniejszy odczyt przez SELECT. Odczyt może być nieaktualny (dwie karty, dwa kliknięcia).
  • xp = xp + ? zmienia wartość względem obecnej — baza dolicza do tego, co ma w chwili zapisu. Odczyt XP do PHP, dodanie i zapisanie gotowej liczby nadpisuje zmianę zrobioną w międzyczasie.
  • insert_id (PDO: lastInsertId()) daje id nadane przez AUTO_INCREMENT. Nie trzeba go zgadywać ani wyliczać.
  • bind_param() przyjmuje tylko zmienne. bind_param('i', 5) albo bind_param('i', (int) $_POST['id']) kończy się błędem („cannot be passed by reference”).
  • Dwa zapytania to nie jedna operacja. Jeśli pierwsze się uda, a drugie nie, pierwsza zmiana zostaje w bazie (zadanie wykonane, XP nieprzyznane). Rozwiązaniem jest transakcja — zadanie końcowe C.
  • Każda strona zmieniająca dane działa w schemacie POST → przekierowanie → GET: sprawdź metodę żądania, zapisz zmianę, header('Location: ...') i exit;. Bez tego F5 powtarza operację.

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

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

  1. INSERT INTO tabela (kolumny) VALUES (wartości) — kolumny i wartości w tej samej kolejności; id z AUTO_INCREMENT i kolumny z wartością domyślną można pominąć.
  2. UPDATE i DELETE zawsze z WHERE. Bez WHERE = cała tabela, nieodwracalnie.
  3. Po INSERT / UPDATE / DELETE nie ma get_result(). Efekt to affected_rows (PDO: rowCount()), nowe id to insert_id (PDO: lastInsertId()).
  4. affected_rows == 0 znaczy „nic się nie zmieniło”, a nie „nie ma takiego wiersza”.
  5. Każde zapytanie, które mogłoby trafić w cudzy rekord, ma AND uzytkownik_id = ?. Prepared statement tego nie załatwia.
  6. Warunek stanu (AND data_wykonania IS NULL) umieszczamy w tym samym UPDATE, który zmienia dane — nie w osobnym if w PHP.
  7. xp = xp + ? zamiast odczytu i zapisu gotowej liczby.
  8. bind_param() przyjmuje zmienne, nie stałe i nie wyrażenia; liczba liter typów = liczba ? (i, s, d).
  9. Dane zmieniamy tylko przez POST, a po zapisie: header('Location: ...'); exit;.
  10. IS NULL, nie = NULL.

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

SQL

INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp)
VALUES (2, 'Tytuł', '', '2026-10-10', 10);

UPDATE zadania SET data_wykonania = CURDATE()
WHERE id = 5 AND uzytkownik_id = 2 AND data_wykonania IS NULL;

UPDATE uzytkownicy SET xp = xp + 10 WHERE id = 2;   -- względem obecnej wartości

DELETE FROM zadania WHERE id = 5 AND uzytkownik_id = 2;

CURDATE() → dzisiejsza data (RRRR-MM-DD), NOW() → data z godziną.

MySQLi obiektowo (TaskQuest)

$zapytanie = $polaczenie->prepare('INSERT INTO zadania (uzytkownik_id, tytul) VALUES (?, ?)');
$zapytanie->bind_param('is', $uzytkownikId, $tytul);
$zapytanie->execute();

$noweId = $zapytanie->insert_id;        // id nadane przez AUTO_INCREMENT
$ile    = $zapytanie->affected_rows;    // ile wierszy się zmieniło

Typy w bind_param(): i — liczba całkowita, s — tekst (także data), d — liczba z przecinkiem.

Schemat funkcji zmieniającej dane

function oznaczZadanieJakoWykonane(mysqli $polaczenie, int $zadanieId, int $uzytkownikId): bool
{
    $zapytanie = $polaczenie->prepare(
        'UPDATE zadania SET data_wykonania = CURDATE()
         WHERE id = ? AND uzytkownik_id = ? AND data_wykonania IS NULL'
    );
    $zapytanie->bind_param('ii', $zadanieId, $uzytkownikId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
}

PDO

$stmt = $pdo->prepare('UPDATE players SET email = ? WHERE id = ?');
$stmt->execute([$email, $id]);

$ile    = $stmt->rowCount();
$noweId = $pdo->lastInsertId();   // na POŁĄCZENIU, nie na zapytaniu

Strona zmieniająca dane (POST → przekierowanie → GET)

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Location: index.php');
    exit;
}
// walidacja → zapis → komunikat w sesji
header('Location: index.php');
exit;                       // ZAWSZE exit po header()

Wersja proceduralna — tylko na egzaminie

Arkusz INF.03 udostępnia wyłącznie te funkcje, więc zapis wygląda tak:

$polaczenie = mysqli_connect('localhost', 'root', '', 'wodospady');

if (isset($_POST['idObiekt'])) {
    $idObiekt  = $_POST['idObiekt'];
    $idTurysta = $_POST['idTurysta'];
    mysqli_query($polaczenie, "INSERT INTO osiagniecia (idObiekt, idTurysta)
                               VALUES ('$idObiekt', '$idTurysta')");
}

mysqli_close($polaczenie);

Poza egzaminem tak nie piszemy — to sklejanie SQL z danymi użytkownika, czyli podatność na SQL injection z Lekcji 18.


Quiz – sprawdź się

  1. Uzupełnij polecenie i odpowiedz, co stanie się z pominiętymi kolumnami id oraz data_wykonania:INSERT INTO zadania (uzytkownik_id, tytul, opis, termin, xp)
    ______ (2, 'Powtórka SQL', '', '2026-11-05', 15);

  2. Ola ma 240 XP. Podaj affected_rows po pierwszym i po drugim wykonaniu tego polecenia:UPDATE uzytkownicy SET xp = 250 WHERE id = 2;

  3. Znajdź dwa błędy:$zapytanie = $polaczenie->prepare('UPDATE zadania SET data_wykonania = CURDATE() WHERE id = ?');
    $zapytanie->bind_param('i', $zadanieId);
    $zapytanie->execute();
    $wynik = $zapytanie->get_result();

  4. Dlaczego UPDATE uzytkownicy SET xp = xp + ? WHERE id = ? jest lepsze niż odczytanie XP SELECT-em, dodanie w PHP i zapisanie gotowej liczby przez SET xp = ??

  5. Co się stanie po uruchomieniu tej linii i jak ją poprawić?$zapytanie->bind_param('ii', $zadanieId, (int) $_SESSION['uzytkownik_id']);
    Kolega usunął z zapytania warunek AND uzytkownik_id = ?:DELETE FROM zadania WHERE id = ?
    Twierdzi, że jest bezpiecznie, bo to prepared statement. Co może zrobić zalogowana Ola i dlaczego prepared statement tu nie pomaga?

  6. Uzupełnij warunek tak, żeby gracz nie dostał XP dwa razy za to samo zadanie:UPDATE zadania SET data_wykonania = CURDATE()
    WHERE id = ? AND uzytkownik_id = ? ______

  7. Skrypt zapisuje dane i kończy się linią header('Location: index.php');. Czego brakuje i jakie dwa problemy może to spowodować?

  8. Po INSERT chcemy przekierować użytkownika na profil.php?id=... nowo dodanego gracza. Skąd wziąć to id w MySQLi, a skąd w PDO?

  9. Co zrobi to polecenie uruchomione w phpMyAdmin i czy da się je cofnąć?DELETE FROM zadania;

  10. Przycisk „Zrobione!” można było zrobić jako zwykły link oznacz-wykonane.php?id=3. Podaj dwa konkretne problemy takiej wersji.

  11. Na egzaminie masz napisać skrypt, który po wysłaniu formularza wstawia do tabeli osiagniecia wartości idObiekt i idTurysta pobrane z formularza. Wypisz kolejne kroki skryptu (od połączenia do zamknięcia) i wskaż, który z nich decyduje o tym, że skrypt nie zapisuje niczego przy zwykłym wejściu na stronę.

  12. Zadanie praktyczne. Napisz funkcję zmienEmailGracza(mysqli $polaczenie, int $graczId, string $email): bool, która zmienia e-mail gracza w tabeli players i zwraca true tylko wtedy, gdy wiersz faktycznie się zmienił. Wyjaśnij, kiedy zwróci false mimo poprawnego id.


Odpowiedzi
  1. VALUES. id nada baza (AUTO_INCREMENT), a data_wykonania dostanie NULL — nowe zadanie jest od razu niewykonane.

  2. Pierwsze wykonanie: 1 (240 → 250). Drugie: 0 — wiersz pasuje do WHERE, ale wartość jest już taka sama, więc nic się nie zmieniło.

  3. (a) get_result() po UPDATE — nie ma wyniku do odebrania, efekt sprawdza się przez affected_rows; (b) brak warunku właściciela AND uzytkownik_id = ?, więc każdy zalogowany może oznaczyć cudze zadanie, podmieniając id w formularzu.

  4. Baza dolicza XP do wartości aktualnej w chwili zapisu. Przy odczycie i zapisie gotowej liczby zmiana zrobiona w międzyczasie (druga karta, drugie kliknięcie, inny skrypt) zostaje nadpisana i część XP przepada.

  5. Error — bind_param() przekazuje argumenty przez referencję, więc nie przyjmuje wyrażenia. Wartość trzeba najpierw zapisać w zmiennej: $uzytkownikId = (int) $_SESSION['uzytkownik_id'];, a potem bind_param('ii', $zadanieId, $uzytkownikId).

  6. Ola może w narzędziach przeglądarki (F12) zmienić wartość ukrytego pola id na numer zadania Kamy i je usunąć. Prepared statement pilnuje tylko tego, żeby dane nie stały się kodem SQL — nie sprawdza, czy użytkownik ma prawo do tego wiersza. To brak kontroli uprawnień, nie SQL injection.

  7. AND data_wykonania IS NULL.

  8. Brakuje exit;. Skrypt wykonuje się dalej: może zacząć wypisywać HTML (a przy wcześniejszym wyjściu nagłówek Location w ogóle nie zadziała) i może wykonać kolejne operacje na bazie, mimo że przeglądarka jest już przekierowywana.

  9. MySQLi: $zapytanie->insert_id (proceduralnie mysqli_insert_id($polaczenie)), odczytane zaraz po execute(), przed zamknięciem połączenia. PDO: $pdo->lastInsertId() — na obiekcie połączenia, nie na zapytaniu.

  10. Usunie wszystkie wiersze tabeli zadania, bo nie ma WHERE. Nie da się tego cofnąć — dane wracają tylko z kopii zapasowej albo przez ponowne uruchomienie sql/taskquest.sql.

  11. Przykładowe: (a) odświeżenie strony (F5) powtarza żądanie GET, więc powtarza operację; (b) adres z id można wpisać ręcznie albo podstawić cudzy numer; (c) link da się kliknąć przypadkiem lub otworzyć z historii, a operacja zmieniająca dane nie powinna się wykonywać przy zwykłym wejściu na adres. Zmiana danych należy do POST.

  12. Kroki: połączenie (mysqli_connect) → sprawdzenie, czy formularz został wysłany (isset($_POST[...])) → pobranie wartości z $_POST do zmiennych → zbudowanie i wykonanie INSERT przez mysqli_query() → zamknięcie połączenia (mysqli_close). O tym, że zwykłe wejście na stronę niczego nie zapisuje, decyduje warunek z isset().

  13. Przykładowe rozwiązanie:function zmienEmailGracza(mysqli $polaczenie, int $graczId, string $email): bool
    {
    $zapytanie = $polaczenie->prepare('UPDATE players SET email = ? WHERE id = ?');
    $zapytanie->bind_param('si', $email, $graczId);
    $zapytanie->execute();

    return $zapytanie->affected_rows === 1;
    }
    Zwróci false także wtedy, gdy gracz istnieje, ale ma już dokładnie ten sam e-mail — UPDATE nie zmienił żadnej wartości, więc affected_rows wynosi 0. Dlatego tego wyniku nie wolno czytać jako „nie ma takiego gracza”.