Dataczwartek, 13 sierpnia 2026 Czas18:33:43
← Aplikacje mobilne (.NET MAUI)

Lekcja 18. Projekt: sklep z produktami i koszykiem (SQLite, dwie powiązane tabele)

Trudny / egzaminacyjny

Po co się tego uczymy?

Poprzednia lekcja pokazała SQLite na przykładzie JEDNEJ tabeli notatek. W prawdziwych aplikacjach dane niemal zawsze są ze sobą POWIĄZANE — produkt należy do koszyka, zamówienie należy do klienta. Ten projekt pokazuje, jak zbudować dwie powiązane tabele (Produkty i Koszyk), połączyć je zapytaniem SQL JOIN, i bezpiecznie parametryzować zapytania — dokładnie ten typ zadania, jaki pojawia się na egzaminie INF.04 w części o bazach danych w aplikacji mobilnej.

Teoria

W tej lekcji poznajemy DRUGIE podejście do SQLite w MAUI — zamiast biblioteki sqlite-net-pcl (atrybuty [PrimaryKey], gotowe metody InsertAsync/DeleteAsync z poprzedniej lekcji), używamy pakietu Microsoft.Data.Sqlite (razem z SQLitePCLRaw.bundle_e_sqlite3, który dostarcza silnik SQLite dla Androida/iOS) — to podejście bliższe "surowemu" SQL-owi, z jawnie pisanymi zapytaniami SqliteCommand.CommandText. Oba podejścia są poprawne i akceptowane na egzaminie; warto znać obydwa, bo różne materiały i różne zadania mogą używać różnej biblioteki.

Łańcuch przepływu danych w tym projekcie: XAML (wygląd: Entry, Button, CollectionView) → code-behind strony (obsługa kliknięć: Dodaj/Usuń/Zapisz/Szukaj/Dodaj do koszyka) → klasa logiki bazy BazaDanych.cs (tworzenie tabel i zapytania SQL) → plik sklep.db w FileSystem.AppDataDirectory.

Parametryzowane zapytania — kluczowe zagadnienie bezpieczeństwa. Zwróć uwagę, że ŻADNA wartość od użytkownika nie jest wklejana bezpośrednio do tekstu zapytania SQL (np. "WHERE nazwa = " + txtSzukaj.Text — TAK NIE WOLNO). Zamiast tego używamy symboli zastępczych (@f, @n, @c) i metody cmd.Parameters.AddWithValue("@f", wartosc). To zabezpiecza przed atakiem SQL Injection — gdyby użytkownik wpisał w polu wyszukiwania coś w rodzaju "; DROP TABLE Produkty; --, sparametryzowane zapytanie potraktuje to jako zwykły tekst do wyszukania, a NIE jako polecenie SQL do wykonania. To ta sama zasada bezpieczeństwa, którą poznałeś (albo poznasz) w dziale o aplikacjach webowych z PHP i bazami danych.

Relacja między tabelami przez klucz obcy. Tabela Koszyk nie przechowuje nazwy ani ceny produktu — trzyma tylko produkt_id (odniesienie do wiersza w tabeli Produkty) i ilosc. Żeby wyświetlić czytelną listę koszyka (z nazwą, ceną i sumą), używamy zapytania JOIN, łączącego obie tabele po kluczu:

SELECT Koszyk.id, Produkty.nazwa, Produkty.cena, Koszyk.ilosc,
       (Produkty.cena * Koszyk.ilosc) AS suma
FROM Koszyk
JOIN Produkty ON Koszyk.produkt_id = Produkty.id

Wynik takiego zapytania mapujemy na osobny model KoszykWidok — celowo INNY niż model Produkt, bo reprezentuje POŁĄCZONE dane z dwóch tabel plus wyliczoną kolumnę suma, której w żadnej z tabel fizycznie nie ma (jest liczona przez SQL na bieżąco).

Odczyt wyników zapytania odbywa się przez SqliteDataReader (ExecuteReaderAsync()) w pętli while (await r.ReadAsync()) — dla każdego wiersza wyniku odczytujemy kolumny po kolei metodami r.GetInt32(indeks), r.GetString(indeks), r.GetDouble(indeks), gdzie indeks to numer kolumny w zapytaniu SELECT (licząc od zera).

Schemat

[XAML — MainPage.xaml]                 Tabela Produkty            Tabela Koszyk
   Entry (nazwa, cena, id)              ┌──────────────┐          ┌──────────────────┐
   CollectionView (produkty)            │ id  (PK)     │◄─────────┤ produkt_id (FK)   │
   CollectionView (koszyk)              │ nazwa        │          │ id (PK)           │
        │                               │ cena         │          │ ilosc             │
        │ Clicked / SelectionChanged    └──────────────┘          └──────────────────┘
        ▼                                      │                          │
[MainPage.xaml.cs]                              └──────────JOIN────────────┘
   btnDodaj_Clicked, btnSzukaj_Clicked,                     │
   listaProdukty_SelectionChanged,                          ▼
   btnDodajDoKoszyka_Clicked                    SELECT ... FROM Koszyk JOIN Produkty
        │                                          → KoszykWidok (Nazwa, Cena, Ilosc, Suma)
        ▼
[BazaDanych.cs] — SqliteConnection + SqliteCommand + parametry (@n, @c, @id)
        │
        ▼
sklep.db (FileSystem.AppDataDirectory)

Przykład z życia

Mała aplikacja sklepowa (np. do inwentaryzacji w niewielkim sklepie osiedlowym) pozwala pracownikowi przeglądać listę produktów, wyszukiwać je po nazwie, a klientowi — dodawać wybrane produkty do koszyka z podliczaną na bieżąco sumą. To uproszczona, ale w pełni działająca wersja mechanizmu, na którym opierają się prawdziwe aplikacje zakupowe: osobna tabela katalogu produktów i osobna tabela zawartości koszyka, połączone relacją.

MainPage.xaml

<!-- MainPage.xaml -->
<ContentPage x:Class="BazaDanychMaui.MainPage"
             xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             Title="Produkty + Koszyk">

    <ScrollView>
        <VerticalStackLayout Padding="12" Spacing="12">

            <Label Text="Dane produktu" FontAttributes="Bold" />
            <Entry x:Name="txtNazwa" Placeholder="Nazwa" />
            <Entry x:Name="txtCena" Placeholder="Cena" Keyboard="Numeric" />
            <Entry x:Name="txtID" Placeholder="ID (edycja/usuwanie)" Keyboard="Numeric" />
            <HorizontalStackLayout Spacing="10">
                <Button Text="Dodaj" Clicked="btnDodaj_Clicked" />
                <Button Text="Zapisz" Clicked="btnZapisz_Clicked" />
                <Button Text="Usuń" Clicked="btnUsun_Clicked" />
            </HorizontalStackLayout>

            <Label Text="Wyszukiwanie" FontAttributes="Bold" />
            <Entry x:Name="txtSzukaj" Placeholder="Szukaj po nazwie..." />
            <HorizontalStackLayout Spacing="10">
                <Button Text="Szukaj" Clicked="btnSzukaj_Clicked" />
                <Button Text="Odśwież" Clicked="btnOdswiez_Clicked" />
            </HorizontalStackLayout>

            <Label Text="Produkty (kliknij, żeby wypełnić pola)" FontAttributes="Bold" />
            <CollectionView x:Name="listaProdukty" SelectionMode="Single"
                            SelectionChanged="listaProdukty_SelectionChanged">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <Grid Padding="8" ColumnDefinitions="60,*,90">
                            <Label Text="{Binding Id}" Grid.Column="0" />
                            <Label Text="{Binding Nazwa}" Grid.Column="1" />
                            <Label Text="{Binding Cena}" Grid.Column="2" HorizontalTextAlignment="End" />
                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>

            <Label Text="Koszyk" FontAttributes="Bold" />
            <Entry x:Name="txtIlosc" Placeholder="Ilość (do koszyka)" Keyboard="Numeric" />
            <Button Text="Dodaj do koszyka (używa ID z pola ID)" Clicked="btnDodajDoKoszyka_Clicked" />
            <CollectionView x:Name="listaKoszyk">
                <CollectionView.ItemTemplate>
                    <DataTemplate>
                        <Grid Padding="8" ColumnDefinitions="60,*,70,60,90">
                            <Label Text="{Binding Id}" Grid.Column="0" />
                            <Label Text="{Binding Nazwa}" Grid.Column="1" />
                            <Label Text="{Binding Cena}" Grid.Column="2" HorizontalTextAlignment="End" />
                            <Label Text="{Binding Ilosc}" Grid.Column="3" HorizontalTextAlignment="End" />
                            <Label Text="{Binding Suma}" Grid.Column="4" HorizontalTextAlignment="End" />
                        </Grid>
                    </DataTemplate>
                </CollectionView.ItemTemplate>
            </CollectionView>

        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

Modele/Produkt.cs + KoszykWidok.cs + Logika/BazaDanych.cs + MainPage.xaml.cs (fragment)

// Modele/Produkt.cs i Modele/KoszykWidok.cs
namespace BazaDanychMaui.Modele;

public class Produkt
{
    public int Id { get; set; }
    public string Nazwa { get; set; } = "";
    public double Cena { get; set; }
}

// "Widok" koszyka po JOIN - gotowy do wyświetlenia, z wyliczoną sumą
public class KoszykWidok
{
    public int Id { get; set; }
    public string Nazwa { get; set; } = "";
    public double Cena { get; set; }
    public int Ilosc { get; set; }
    public double Suma { get; set; }
}

// ---------------------------------------------------------------------

// Logika/BazaDanych.cs
using Microsoft.Data.Sqlite;
using BazaDanychMaui.Modele;

namespace BazaDanychMaui.Logika;

public class BazaDanych
{
    private readonly string _sciezkaBazy;
    private string ConnectionString => $"Data Source={_sciezkaBazy}";

    public BazaDanych()
    {
        SQLitePCL.Batteries_V2.Init(); // inicjalizacja silnika SQLite (Android/iOS)
        _sciezkaBazy = Path.Combine(FileSystem.AppDataDirectory, "sklep.db");
        UtworzTabeleJesliNieIstnieja();
    }

    private void UtworzTabeleJesliNieIstnieja()
    {
        using var con = new SqliteConnection(ConnectionString);
        con.Open();

        using var cmd1 = con.CreateCommand();
        cmd1.CommandText = "CREATE TABLE IF NOT EXISTS Produkty (" +
            "id INTEGER PRIMARY KEY AUTOINCREMENT, nazwa TEXT NOT NULL, cena REAL NOT NULL);";
        cmd1.ExecuteNonQuery();

        using var cmd2 = con.CreateCommand();
        cmd2.CommandText = "CREATE TABLE IF NOT EXISTS Koszyk (" +
            "id INTEGER PRIMARY KEY AUTOINCREMENT, produkt_id INTEGER NOT NULL, ilosc INTEGER NOT NULL);";
        cmd2.ExecuteNonQuery();
    }

    // -------------------- PRODUKTY --------------------

    public async Task<List<Produkt>> PobierzProduktyAsync(string filtr = "")
    {
        var lista = new List<Produkt>();
        await using var con = new SqliteConnection(ConnectionString);
        await con.OpenAsync();

        string sql = "SELECT id, nazwa, cena FROM Produkty";
        if (!string.IsNullOrWhiteSpace(filtr)) sql += " WHERE nazwa LIKE @f";

        await using var cmd = con.CreateCommand();
        cmd.CommandText = sql;
        if (!string.IsNullOrWhiteSpace(filtr))
            cmd.Parameters.AddWithValue("@f", "%" + filtr + "%"); // parametr, NIE sklejanie stringów

        await using var r = await cmd.ExecuteReaderAsync();
        while (await r.ReadAsync())
        {
            lista.Add(new Produkt { Id = r.GetInt32(), Nazwa = r.GetString(1), Cena = r.GetDouble(2) });
        }
        return lista;
    }

    public async Task DodajProduktAsync(string nazwa, double cena)
    {
        await using var con = new SqliteConnection(ConnectionString);
        await con.OpenAsync();
        await using var cmd = con.CreateCommand();
        cmd.CommandText = "INSERT INTO Produkty(nazwa, cena) VALUES (@n, @c)";
        cmd.Parameters.AddWithValue("@n", nazwa);
        cmd.Parameters.AddWithValue("@c", cena);
        await cmd.ExecuteNonQueryAsync();
    }

    public async Task UsunProduktAsync(int id)
    {
        await using var con = new SqliteConnection(ConnectionString);
        await con.OpenAsync();
        await using var cmd = con.CreateCommand();
        cmd.CommandText = "DELETE FROM Produkty WHERE id=@id";
        cmd.Parameters.AddWithValue("@id", id);
        await cmd.ExecuteNonQueryAsync();
    }

    // -------------------- KOSZYK --------------------

    public async Task DodajDoKoszykaAsync(int produktId, int ilosc)
    {
        await using var con = new SqliteConnection(ConnectionString);
        await con.OpenAsync();
        await using var cmd = con.CreateCommand();
        cmd.CommandText = "INSERT INTO Koszyk(produkt_id, ilosc) VALUES (@pid, @ilosc)";
        cmd.Parameters.AddWithValue("@pid", produktId);
        cmd.Parameters.AddWithValue("@ilosc", ilosc);
        await cmd.ExecuteNonQueryAsync();
    }

    public async Task<List<KoszykWidok>> PobierzKoszykAsync()
    {
        var lista = new List<KoszykWidok>();
        await using var con = new SqliteConnection(ConnectionString);
        await con.OpenAsync();
        await using var cmd = con.CreateCommand();
        cmd.CommandText = "SELECT Koszyk.id, Produkty.nazwa, Produkty.cena, Koszyk.ilosc, " +
            "(Produkty.cena * Koszyk.ilosc) AS suma FROM Koszyk JOIN Produkty ON Koszyk.produkt_id = Produkty.id";
        await using var r = await cmd.ExecuteReaderAsync();
        while (await r.ReadAsync())
        {
            lista.Add(new KoszykWidok
            {
                Id = r.GetInt32(), Nazwa = r.GetString(1), Cena = r.GetDouble(2),
                Ilosc = r.GetInt32(3), Suma = r.GetDouble(4)
            });
        }
        return lista;
    }
}

// ---------------------------------------------------------------------

// MainPage.xaml.cs (fragment - najważniejsze metody)
using BazaDanychMaui.Logika;
using BazaDanychMaui.Modele;

namespace BazaDanychMaui;

public partial class MainPage : ContentPage
{
    private readonly BazaDanych _baza = new BazaDanych();

    public MainPage()
    {
        InitializeComponent();
        _ = OdswiezProduktyAsync();
        _ = OdswiezKoszykAsync();
    }

    private async Task OdswiezProduktyAsync(string filtr = "")
        => listaProdukty.ItemsSource = await _baza.PobierzProduktyAsync(filtr);

    private async Task OdswiezKoszykAsync()
        => listaKoszyk.ItemsSource = await _baza.PobierzKoszykAsync();

    private async void btnDodaj_Clicked(object sender, EventArgs e)
    {
        if (string.IsNullOrWhiteSpace(txtNazwa.Text)) { await DisplayAlert("Błąd", "Podaj nazwę!", "OK"); return; }
        if (!double.TryParse(txtCena.Text, out double cena)) { await DisplayAlert("Błąd", "Cena musi być liczbą.", "OK"); return; }

        await _baza.DodajProduktAsync(txtNazwa.Text.Trim(), cena);
        await OdswiezProduktyAsync();
    }

    private void listaProdukty_SelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (e.CurrentSelection?.FirstOrDefault() is Produkt p)
        {
            txtID.Text = p.Id.ToString();
            txtNazwa.Text = p.Nazwa;
            txtCena.Text = p.Cena.ToString();
        }
    }

    private async void btnDodajDoKoszyka_Clicked(object sender, EventArgs e)
    {
        if (!int.TryParse(txtID.Text, out int produktId)) { await DisplayAlert("Błąd", "Najpierw wybierz produkt.", "OK"); return; }
        if (!int.TryParse(txtIlosc.Text, out int ilosc) || ilosc <= ) { await DisplayAlert("Błąd", "Podaj poprawną ilość (>0).", "OK"); return; }

        await _baza.DodajDoKoszykaAsync(produktId, ilosc);
        await OdswiezKoszykAsync();
    }
}

Komentarz i wyjaśnienie kodu

SQLitePCL.Batteries_V2.Init() w konstruktorze BazaDanych to linia, o której łatwo zapomnieć, a bez niej aplikacja na Androidzie/iOS zgłosi błąd braku natywnej biblioteki SQLite — Microsoft.Data.Sqlite (w przeciwieństwie do sqlite-net-pcl z poprzedniej lekcji) wymaga tej jawnej inicjalizacji.

listaProdukty_SelectionChanged pokazuje przydatny wzorzec UX: kliknięcie produktu na liście automatycznie WYPEŁNIA pola formularza jego danymi — dzięki temu przyciski "Zapisz" (edycja) i "Usuń" wiedzą, o który rekord chodzi, bez potrzeby ręcznego przepisywania ID przez użytkownika.

Metody OdswiezProduktyAsync/OdswiezKoszykAsync są wywoływane w konstruktorze przez _ = OdswiezProduktyAsync(); — podkreślnik _ to celowe zignorowanie zwracanego Task (konstruktor nie może być async, więc nie możemy użyć tam await), ale metoda i tak wykona się asynchronicznie w tle.

Ćwiczenie samodzielne

Uruchom powyższy projekt, dodaj kilka produktów, wyszukaj je po fragmencie nazwy, a następnie dodaj wybrany produkt do koszyka kilka razy z różną ilością — sprawdź, czy suma w kolumnie "Suma" jest poprawnie wyliczana przez zapytanie JOIN.

Zadania do pracy własnej

  1. Dodaj przycisk "Wyczyść koszyk" wywołujący nową metodę WyczyscKoszykAsync() w BazaDanych (SQL: DELETE FROM Koszyk, bez warunku WHERE — usuwa wszystkie wiersze).

  2. Dodaj metodę UsunZKoszykaAsync(int koszykId) oraz przycisk przy każdej pozycji w liście koszyka (analogicznie do wzorca BindingContext z lekcji o CollectionView), pozwalający usunąć pojedynczą pozycję z koszyka bez czyszczenia całości.

  3. Rozbuduj projekt o podsumowanie całego koszyka: etykieta pod listą koszyka pokazująca SUMĘ WSZYSTKICH pozycji (SQL: SELECT SUM(Produkty.cena * Koszyk.ilosc) FROM Koszyk JOIN Produkty ON ..., odczytane pojedynczą wartością przez cmd.ExecuteScalarAsync() zamiast ExecuteReaderAsync()), aktualizowaną po każdej zmianie zawartości koszyka.

Typowe błędy

Sklejanie zapytania SQL ze stringów zawierających dane od użytkownika (np. "WHERE nazwa = '" + txtSzukaj.Text + "'") zamiast parametrów @f — to poważna luka bezpieczeństwa (SQL Injection), NIGDY nie powinno się tak robić, nawet w prostym projekcie szkolnym.

Brak SQLitePCL.Batteries_V2.Init() przy używaniu Microsoft.Data.Sqlite — bez tego aplikacja może działać poprawnie na Windows (gdzie silnik SQLite bywa dostępny inaczej), ale zawiedzie na Androidzie lub iOS.

Mylenie indeksów kolumn w r.GetString(indeks) z kolejnością pól w klasie modelu — indeks musi odpowiadać KOLEJNOŚCI kolumn w zapytaniu SELECT, nie kolejności właściwości w klasie C#; zmiana kolejności w SQL bez odpowiedniej zmiany w kodzie odczytu prowadzi do przypisania złych wartości do złych pól.

Nieużywanie using/await using przy połączeniach i poleceniach — bez tego połączenia z bazą nie są prawidłowo zamykane i zwalniane, co przy wielu operacjach może prowadzić do wyczerpania dostępnych zasobów.

Nawiązanie do egzaminu zawodowego

To rozszerzenie wymagań INF.04.6.2 (baza danych w aplikacji mobilnej) o praktyczne zastosowanie relacji między tabelami (INF.03.4.2 — związki encji) i zapytań złączających JOIN (INF.03.4.4 — stosuje SQL) w kontekście mobilnym. Parametryzowane zapytania to też bezpośrednie nawiązanie do zasad bezpieczeństwa aplikacji korzystających z baz danych, niezależnie od tego, czy to aplikacja mobilna, desktopowa czy webowa.