Lekcja 18. Projekt: sklep z produktami i koszykiem (SQLite, dwie powiązane tabele)
Trudny / egzaminacyjnyPo 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
Dodaj przycisk "Wyczyść koszyk" wywołujący nową metodę
WyczyscKoszykAsync()wBazaDanych(SQL:DELETE FROM Koszyk, bez warunku WHERE — usuwa wszystkie wiersze).Dodaj metodę
UsunZKoszykaAsync(int koszykId)oraz przycisk przy każdej pozycji w liście koszyka (analogicznie do wzorcaBindingContextz lekcji o CollectionView), pozwalający usunąć pojedynczą pozycję z koszyka bez czyszczenia całości.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ą przezcmd.ExecuteScalarAsync()zamiastExecuteReaderAsync()), 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.