Grafikus C# – Windows Forms tanfolyam › 3.10 lecke

Windows Forms · 3.10 lecke · több tábla

Kapcsolatok, elsődleges és idegen kulcsok, egyszerű tranzakciók

Eddig minden adatot egyetlen táblában tároltunk. A valódi adatbázisok több, egymással kapcsolatban álló táblából állnak: egy osztályhoz sok diák tartozik, egy diák egy osztályba jár. Ebben a leckében a kapcsolatot idegen kulccsal építed fel, a táblákat a JOIN kapcsolja össze a lekérdezésben, és megtanulod, hogyan lehet több műveletet tranzakcióban végrehajtani, hogy vagy mind sikerüljön, vagy semmi.

  • kb. 120 perc
  • haladó szint
  • kulcsok és tranzakciók
Diáklány C# programot készít laptopon

A lecke végére

  • érted az elsődleges kulcs és az idegen kulcs szerepét, és az egy-a-többhöz kapcsolatot;
  • két táblát hozol létre FOREIGN KEY kapcsolattal, és bekapcsolod az ellenőrzését SQLite-ban (Foreign Keys=True);
  • INNER JOIN és LEFT JOIN lekérdezéssel összekapcsolt adatokat kérdezel le;
  • a ComboBox DataSource, DisplayMember és ValueMember tulajdonságával a felhasználó nevet lát, a program azonosítót kap;
  • tranzakciót használsz (BeginTransaction, Commit, Rollback), és érted, mikor szükséges;
  • felismered a leggyakoribb hibákat (kikapcsolt kulcsellenőrzés, elfelejtett Commit, kétértelmű oszlopnév).

Mielőtt elkezded

Ehhez a leckéhez kell a 3.5 lecke (WHERE, GROUP BY), a 3.6 lecke (INSERT, ExecuteNonQuery, LastInsertRowId), a 3.7 lecke (paraméterek) és a 3.8 lecke (DataTable). Az egész lecke egy új, gyakorló adatbázissal dolgozik, ezért a korábbi adataid nem sérülnek.

3.9 CRUD űrlap →

Miért kell több tábla? Elsődleges és idegen kulcs

Képzeld el, hogy egyetlen táblában tárolod a diákokat és az osztályaikat is: minden diák sorában ott áll, hogy 10.A. Ha az osztály nevét át kell írni (például 10.A helyett 10.A (emelt)), az összes diák sorát módosítani kell, és ha egy sorban elírod, az adatok ellentmondásossá válnak. A megoldás: az osztályok külön táblába kerülnek, és a diák csak az osztály azonosítóját tárolja:

FogalomJelentésePélda
Elsődleges kulcs (PRIMARY KEY)A tábla minden sorát egyedileg azonosító oszlop.osztalyok.id
Idegen kulcs (FOREIGN KEY)Egy oszlop, amely egy másik tábla elsődleges kulcsára hivatkozik.diakok.osztalyid → osztalyok.id
Egy-a-többhöz kapcsolatAz egyik tábla egy sorához a másik tábla több sora tartozhat.Egy osztályhoz több diák.
Hivatkozási épségAz adatbázis nem engedi, hogy a hivatkozás nem létező sorra mutasson.Nem vehető fel diák a 99-es azonosítójú osztályba, ha nincs ilyen.

Hozz létre egy új, gyakorló adatbázist (például iskola.db) a DB Browserben, és futtasd az Execute SQL lapon (majd Ctrl+S):

SQLSQLite
CREATE TABLE osztalyok (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nev TEXT NOT NULL UNIQUE
);

CREATE TABLE diakok (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nev TEXT NOT NULL,
osztalyid INTEGER,
FOREIGN KEY (osztalyid) REFERENCES osztalyok (id)
);

INSERT INTO osztalyok (nev) VALUES ('10.A'), ('10.B'), ('11.A');

INSERT INTO diakok (nev, osztalyid) VALUES ('Kovács Anna', 1);
INSERT INTO diakok (nev, osztalyid) VALUES ('Nagy Péter', 1);
INSERT INTO diakok (nev, osztalyid) VALUES ('Szabó Kata', 2);
INSERT INTO diakok (nev, osztalyid) VALUES ('Tóth Bence', 2);
INSERT INTO diakok (nev, osztalyid) VALUES ('Kiss Márta', 2);
INSERT INTO diakok (nev, osztalyid) VALUES ('Varga Dávid', 3);
INSERT INTO diakok (nev, osztalyid) VALUES ('Fekete Dóra', NULL); -- még nincs osztálya
FOREIGN KEY (osztalyid) REFERENCES osztalyok (id)Kimondja: a diakok.osztalyid értéke csak olyan szám lehet, amely az osztalyok tábla id oszlopában szerepel (vagy NULL).
osztalyid INTEGER (NULL megengedett)Mivel nem NOT NULL, egy diáknak lehet, hogy még nincs osztálya. (Fekete Dóra.)
INSERT ... VALUES (...), (...)Több sort is felvehetsz egyetlen utasítással, a sorokat vesszővel elválasztva.
nev TEXT NOT NULL UNIQUEAz osztály neve kötelező, és nem lehet két azonos nevű osztály.

Az SQLite alapból nem ellenőrzi az idegen kulcsot!

Az SQLite-ban a FOREIGN KEY ellenőrzése kapcsolatonként kapcsolható be, és alapból ki van kapcsolva. A DB Browserben az Edit Pragmas lapon a Foreign Keys pipa megfelelő beállításáról győződj meg (általában be van kapcsolva). A C# programban a kapcsolati szövegbe kell írni: Foreign Keys=True;. Enélkül a program nem létező osztályazonosítót is elfogad, és a hivatkozási épség csendben megsérül.

DB.cs (az UjKapcsolat metódusban; ehhez az iskola.db legyen a program mellett)C#
return new SQLiteConnection("Data Source=" + AdatbazisUt + ";Version=3;Foreign Keys=True;");

Jósolj!

Bekapcsolt kulcsellenőrzés mellett a INSERT INTO diakok (nev, osztalyid) VALUES ('Új Diák', 99) utasítást futtatod, de nincs 99-es osztály. Mi történik? És az DELETE FROM osztalyok WHERE id = 2 utasítással, amikor a 10.B osztályban még vannak diákok?

Jóslat ellenőrzése

Mindkettő hibát ad: FOREIGN KEY constraint failed. Az első azért, mert a 99-es osztály nem létezik (a hivatkozás érvénytelen lenne). A második azért, mert a 10.B osztályra még diákok hivatkoznak, és törlésével árva (gazdátlan) hivatkozások keletkeznének. Az adatbázis így védi az adatok épségét. (A viselkedést az ON DELETE kiegészítéssel lehet módosítani: SET NULL ilyenkor a diákok osztályát törli, CASCADE a diákokat is törli.)

JOIN: a két tábla összekapcsolása a lekérdezésben

A diák táblában csak az osztály azonosítója szerepel (1, 2, 3). Olvasható listához az osztály nevét kell mellétenni. Ezt a JOIN végzi: a két tábla sorait az ON feltétel szerint párosítja:

SQLSQLite
-- csak azok a diákok, akiknek van osztályuk
SELECT d.id, d.nev AS diak, o.nev AS osztaly
FROM diakok d
INNER JOIN osztalyok o ON d.osztalyid = o.id
ORDER BY o.nev, d.nev;

-- minden diák, osztály nélkül is (az osztaly oszlop ilyenkor NULL)
SELECT d.id, d.nev AS diak, o.nev AS osztaly
FROM diakok d
LEFT JOIN osztalyok o ON d.osztalyid = o.id
ORDER BY o.nev, d.nev;

-- osztályonként a létszám, az üres osztályokat is mutatva
SELECT o.nev AS osztaly, COUNT(d.id) AS letszam
FROM osztalyok o
LEFT JOIN diakok d ON d.osztalyid = o.id
GROUP BY o.id, o.nev
ORDER BY o.nev;
diakok d, osztalyok oA tábla neve után álló betű egy rövid név (alias). Ezzel hivatkozunk az oszlopokra: d.nev, o.nev.
ON d.osztalyid = o.idA párosítás feltétele: a diák osztályazonosítója egyezzen az osztály azonosítójával.
INNER JOINCsak azokat a sorokat adja, amelyeknek mindkét oldalon van párjuk. A osztály nélküli diák kimarad.
LEFT JOINA bal oldali tábla (itt a FROM után álló) minden sorát megtartja, a hiányzó párokat NULL értékkel tölti ki.
d.nev AS diakMindkét táblában van nev oszlop: az alias nélkül kétértelmű oszlopnév (ambiguous column name) hibát kapnál, és az eredmény két nev oszlopa is összekeveredne.
COUNT(d.id)Csak a ténylegesen létező diákokat számolja (a NULL értéket kihagyja). A COUNT(*) az üres osztályra is 1-et adna, mert a LEFT JOIN egy sort hagy meg hozzá.

Jósolj!

Hány sort ad az INNER JOIN, és hányat a LEFT JOIN a fenti adatokkal? Szerepel-e Fekete Dóra mindkettőben? Mi az osztaly oszlop az ő sorában?

Jóslat ellenőrzése

Az INNER JOIN 6 sort ad (azok a diákok, akiknek van osztályuk), a LEFT JOIN 7 sort. Fekete Dóra csak a LEFT JOIN eredményében szerepel, és az osztaly oszlop nála NULL (üres). Ha az összekapcsolt listában mindenkit látni szeretnél, LEFT JOIN kell.

Az ON nélküli JOIN mindent mindennel párosít

Ha az ON feltétel kimarad (vagy hibás), a két tábla minden sora minden sorral párosul (keresztszorzat): 7 diák és 3 osztály esetén 21 sor, nagy táblákon pedig hatalmas eredmény. Mindig ellenőrizd, hogy az ON a megfelelő kulcsokat kapcsolja össze.

C#: JOIN a táblázatban, osztály választása a ComboBoxból

Előkészület: az új adatbázis a program mellé kerül (Copy if newer, 3.2 lecke), a DB.cs osztályban pedig az AdatbazisUt fájlneve legyen iskola.db (belepes.db helyett). Ezt a leckét egy új projektben is megcsinálhatod, hogy a korábbi munkáidhoz ne kelljen hozzányúlni.

A JOIN-nal összekapcsolt lekérdezés eredménye is egy egyszerű DataTable, amelyet a DataGridView megjelenít. Az adatkezelő osztály (DB) három új metódust kap. A legfontosabb szabály itt is: a felhasználó választását (az osztály azonosítóját) paraméterként adjuk át:

DB.cs (a fájl elején: using System.Data;)C#
public static DataTable Osztalyok()
{
DataTable tabla = new DataTable();

using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteDataAdapter adapter = new SQLiteDataAdapter("SELECT id, nev FROM osztalyok ORDER BY nev", kapcsolat))
{
adapter.Fill(tabla);
}
}

return tabla;
}

// osztalyId == 0: minden diák; egyébként csak a választott osztály diákjai
public static DataTable Diakok(int osztalyId)
{
string sql = "SELECT d.id, d.nev AS diak, o.nev AS osztaly "
+ "FROM diakok d LEFT JOIN osztalyok o ON d.osztalyid = o.id "
+ "WHERE (@oszt = 0 OR d.osztalyid = @oszt) "
+ "ORDER BY o.nev, d.nev";

DataTable tabla = new DataTable();

using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteCommand parancs = new SQLiteCommand(sql, kapcsolat))
{
parancs.Parameters.AddWithValue("@oszt", osztalyId);

using (SQLiteDataAdapter adapter = new SQLiteDataAdapter(parancs))
{
adapter.Fill(tabla);
}
}
}

return tabla;
}

// osztalyId == 0: a diák osztály nélkül kerül be (NULL)
public static long DiakFelvetel(string nev, int osztalyId)
{
using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteCommand parancs = new SQLiteCommand("INSERT INTO diakok (nev, osztalyid) VALUES (@nev, @oszt)", kapcsolat))
{
parancs.Parameters.AddWithValue("@nev", nev);
parancs.Parameters.AddWithValue("@oszt", osztalyId == 0 ? (object)DBNull.Value : osztalyId);
parancs.ExecuteNonQuery();
}

return kapcsolat.LastInsertRowId;
}
}

Az ablakban a ComboBox az osztály nevét mutatja, de a program az azonosítót szeretné megkapni. Ezt a DataSource, a DisplayMember és a ValueMember hármas oldja meg: a lista egy DataTable-ből tölti fel magát, a DisplayMember a megjelenített oszlop neve, a ValueMember az értéket adó oszlop:

Form1.csC#
private bool betoltve = false;

private void Form1_Load(object sender, EventArgs e)
{
try
{
// szűrő: (összes) + az osztályok
DataTable osztalyok = DB.Osztalyok();
DataRow osszes = osztalyok.NewRow();
osszes["id"] = 0;
osszes["nev"] = "(összes)";
osztalyok.Rows.InsertAt(osszes, 0);

cmbSzuro.DisplayMember = "nev"; // előbb a tagok, utána az adatforrás
cmbSzuro.ValueMember = "id";
cmbSzuro.DataSource = osztalyok;

// az új diákhoz tartozó lista: külön DataTable, hogy a két lista független legyen
DataTable osztalyok2 = DB.Osztalyok();
DataRow nincs = osztalyok2.NewRow();
nincs["id"] = 0;
nincs["nev"] = "(még nincs osztálya)";
osztalyok2.Rows.InsertAt(nincs, 0);

cmbUjOsztaly.DisplayMember = "nev";
cmbUjOsztaly.ValueMember = "id";
cmbUjOsztaly.DataSource = osztalyok2;
}
catch (Exception ex)
{
DB.Naploz(ex);
MessageBox.Show("Az osztályok betöltése nem sikerült.");
return;
}

betoltve = true;
Frissit();
}

private void Frissit()
{
if (!betoltve)
{
return;
}

try
{
int osztalyId = Convert.ToInt32(cmbSzuro.SelectedValue);

dgvDiakok.DataSource = DB.Diakok(osztalyId);
dgvDiakok.Columns["id"].Visible = false;
dgvDiakok.Columns["diak"].HeaderText = "Diák";
dgvDiakok.Columns["osztaly"].HeaderText = "Osztály";
}
catch (Exception ex)
{
DB.Naploz(ex);
MessageBox.Show("A lista betöltése nem sikerült.");
}
}

private void cmbSzuro_SelectedIndexChanged(object sender, EventArgs e)
{
Frissit();
}

private void btnDiakFelvetel_Click(object sender, EventArgs e)
{
string nev = txtDiakNev.Text.Trim();

if (nev.Length == 0)
{
lblUzenet.Text = "Add meg a diák nevét!";
return;
}

try
{
DB.DiakFelvetel(nev, Convert.ToInt32(cmbUjOsztaly.SelectedValue));
txtDiakNev.Clear();
lblUzenet.Text = "A diák felvéve.";
Frissit();
}
catch (Exception ex)
{
DB.Naploz(ex);
lblUzenet.Text = "A felvétel nem sikerült.";
}
}
DisplayMember és ValueMemberA DisplayMember az oszlop, amelynek értékét a felhasználó látja (nev), a ValueMember az, amelyet a program kap (id). A SelectedValue a kiválasztott elem azonosítója.
Előbb a tagok, utána a DataSourceHa a DataSource-ot állítod be előbb, a lista már az első feltöltéskor kiválthatja a SelectedIndexChanged eseményt, amikor a ValueMember még nincs beállítva. Ezért előbb a tagok, utána az adatforrás.
InsertAt(sor, 0)Egy kézzel létrehozott sort ((összes), azonosító 0) tesz a tábla elejére. Így nem kell külön elem a lista tetején.
Két külön DataTableHa két ComboBox ugyanazt a DataTable-t használná, kijelölésük összefonódna (a DataTable közös aktuális elemet jegyez meg). Külön táblából független marad.
@oszt = 0 OR d.osztalyid = @osztA 3.5 leckében látott választható szűrő: 0 esetén minden diák, egyébként csak az adott osztály diákjai.
Columns["id"].Visible = falseAz azonosítót nem kell mutatni, de a táblázatban megmarad, ha később szükség van rá.

Jósolj!

A cmbUjOsztaly listában a (még nincs osztálya) elemet választod, és felveszel egy diákot. Mi kerül az osztalyid oszlopba? Melyik sorban fog megjelenni a táblázatban, ha a szűrő (összes)?

Jóslat ellenőrzése

Az azonosító 0, ezért a DiakFelvetel DBNull.Value értéket ír be (NULL): a kulcsellenőrzés ezt elfogadja (a NULL nem hivatkozik semmire). A LEFT JOIN miatt a diák az (összes) szűrőnél az osztály nélküli sorok között (üres osztály oszloppal) látszik. Ha a 0 azonosítót közvetlenül beírtad volna, a kulcsellenőrzés (nincs 0 azonosítójú osztály) FOREIGN KEY constraint failed hibát adna.

📷 Képernyőkép helye

Mit tegyél ide: a futó program: felül az osztályszűrő (10.B kiválasztva), alatta a táblázat három diákkal, lent a diákfelvétel mezője és osztályválasztója.Javasolt fájlnév: winforms-3-10-nevsor.webp · Alt szöveg: A diáklista osztályszűrővel és felvételi űrlappal

Tranzakciók: vagy mind, vagy semmi

Egy új osztály felvételekor két dolgot kell tenni: létrehozni az osztályt, majd felvenni a diákjait. Mi történik, ha az osztály létrejön, de a harmadik diák felvételénél hiba keletkezik? Egy félig kész osztály marad az adatbázisban. A tranzakció ennek a megoldása: több műveletet egyetlen, oszthatatlan egységként hajt végre. Ha mind sikerül, a Commit véglegesíti őket; ha bármelyik elbukik, a Rollback az összeset visszavonja. A kód:

DB.csC#
public static int OsztalyDiakokkal(string osztalyNev, string[] diakNevek)
{
using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteTransaction tranzakcio = kapcsolat.BeginTransaction())
{
long osztalyId;

using (SQLiteCommand ujOsztaly = new SQLiteCommand("INSERT INTO osztalyok (nev) VALUES (@nev)", kapcsolat, tranzakcio))
{
ujOsztaly.Parameters.AddWithValue("@nev", osztalyNev);
ujOsztaly.ExecuteNonQuery();
osztalyId = kapcsolat.LastInsertRowId;
}

using (SQLiteCommand ujDiak = new SQLiteCommand("INSERT INTO diakok (nev, osztalyid) VALUES (@nev, @oszt)", kapcsolat, tranzakcio))
{
ujDiak.Parameters.Add("@nev", DbType.String);
ujDiak.Parameters.AddWithValue("@oszt", osztalyId);

foreach (string nev in diakNevek)
{
ujDiak.Parameters["@nev"].Value = nev;
ujDiak.ExecuteNonQuery();
}
}

tranzakcio.Commit(); // csak ide jut el, ha minden sikerült
return (int)osztalyId;
} // commit nélkül a using visszavonja (Rollback) a tranzakciót
}
}
BeginTransaction()Elindítja a tranzakciót a kapcsolaton. Ettől kezdve a műveletek még nem véglegesek.
new SQLiteCommand(sql, kapcsolat, tranzakcio)A parancsot a tranzakcióhoz kötjük. Így tudja a rendszer, hogy a parancs a tranzakció része.
Commit()Véglegesíti az összes művelet eredményét, egyszerre.
Rollback (a using hatására)Ha a Commit előtt kivétel keletkezik, a blokk elhagyásakor a SQLiteTransaction felszabadítása automatikusan visszavonja a tranzakciót. Kifejezetten is meghívható: tranzakcio.Rollback().
Parameters.Add(név, DbType)A diák nevének paraméterét egyszer hozzuk létre, és a ciklusban csak az értékét cseréljük. Ugyanazt a parancsot használjuk újra (3.7 lecke).
LastInsertRowId a tranzakcióbanAz osztály azonosítója még a Commit előtt rendelkezésre áll, így a diákok hivatkozhatnak rá.

Jósolj!

A OsztalyDiakokkal("10.A", ...) hívást futtatod, de a 10.A osztály már létezik (az osztalyok.nev egyedi). Mi történik? Marad-e valami az adatbázisban a diákokból?

Jóslat ellenőrzése

Az első INSERT UNIQUE constraint failed hibával elbukik, a kivétel kilép a using blokkból, a tranzakció visszavonódik, és semmi nem kerül az adatbázisba: se osztály, se diák. A hívó try–catch blokkja kezeli a kivételt (például: Ez az osztály már létezik). Hasonló lenne, ha a harmadik diák felvétele bukna el: az osztály és az első két diák is visszavonódna.

A tranzakció gyorsít is

Tranzakció nélkül az SQLite minden egyes INSERT után külön, biztonságosan a lemezre ír, ami lassú. Sok sor felvételekor egyetlen tranzakcióba foglalva a művelet sokszorosan gyorsabb. Próbáld ki: mérd meg az időt a Stopwatch osztállyal (System.Diagnostics), először tranzakció nélkül, majd tranzakcióval, például 300 sor felvételével egy gyakorló táblába. A pontos különbség a géptől függ, de jól látható.

Egy tranzakció, egy kapcsolat

A tranzakció alatt az ugyanazon a kapcsolaton dolgozz. Ha közben egy másik kapcsolat (például egy másik DB metódus, amely újat nyit) ugyanabba az adatbázisba próbál írni, az SQLite database is locked hibát adhat. A tranzakcióban futó kódból tehát ne hívj olyan metódust, amely saját kapcsolatot nyit.

Hibakeresés: te vagy a hibadetektív

Mi a baj, és hogyan javítod?

Gondold végig az okot, és csak utána nyisd le a megoldást.

1. Nem létező osztályba is bekerül a diák

A program a 99-es osztályba is felvesz egy diákot, és nincs hibaüzenet.

Ok és javítás

Az SQLite alapból nem ellenőrzi az idegen kulcsot. Add a kapcsolati szöveghez a Foreign Keys=True; beállítást. Ellenőrizd a DB Browserben is az Edit Pragmas lapon a Foreign Keys beállítást, és a tesztet egy nem létező azonosítóval ismételd meg.

2. FOREIGN KEY constraint failed törléskor

DELETE FROM osztalyok WHERE id = 2;
-- SQLiteException: FOREIGN KEY constraint failed
Hiba és javítás

Ez nem hiba, hanem védelem: a 10.B osztályra még diákok hivatkoznak. A felhasználónak érthető üzenetet kell adni (Az osztályt nem lehet törölni, amíg diákjai vannak). Megoldások: előbb áthelyezed vagy törlöd a diákokat, vagy a tábla létrehozásakor megadod az ON DELETE SET NULL (a diákok osztálya törlődik) illetve ON DELETE CASCADE (a diákok is törlődnek, ez veszélyes!) beállítást.

3. Elfelejtett Commit

using (SQLiteTransaction tranzakcio = kapcsolat.BeginTransaction())
{
// ... INSERT utasítások ...
// tranzakcio.Commit(); kimaradt
}
Hiba és javítás

A Commit nélkül a blokk elhagyásakor a tranzakció visszavonódik, és az összes INSERT elvész, hibaüzenet nélkül. Mindig a sikeres befejezés végén hívd a Commit metódust.

4. A parancs nincs a tranzakcióhoz kötve

SQLiteCommand parancs = new SQLiteCommand(sql, masikKapcsolat);   // másik kapcsolat!
Hiba és javítás

A tranzakció egy kapcsolathoz tartozik. Ha a parancsot másik kapcsolattal hozod létre, nem része a tranzakciónak (a rollback nem érinti), és database is locked hibát is kaphatsz. Használd ugyanazt a kapcsolatot, és add meg a tranzakciót: new SQLiteCommand(sql, kapcsolat, tranzakcio).

5. Kétértelmű oszlopnév

SELECT nev, nev FROM diakok d JOIN osztalyok o ON d.osztalyid = o.id;
-- Error: ambiguous column name: nev
Hiba és javítás

Mindkét táblának van nev oszlopa, ezért az SQL nem tudja, melyiket kéred. Használj táblaneveket vagy rövid neveket: d.nev AS diak, o.nev AS osztaly. Az AS egyedi oszlopnevet is ad az eredménynek.

6. Túl sok sor a JOIN-tól

SELECT d.nev, o.nev FROM diakok d, osztalyok o;   -- 21 sor, nem 7!
Hiba és javítás

A vesszővel elválasztott táblák között nincs összekapcsoló feltétel, ezért minden sor minden sorral párosul (keresztszorzat). Használj JOIN ... ON feltételt, vagy WHERE d.osztalyid = o.id szűrést.

Mini projekt: osztálynévsor

Állítsd össze a teljes osztálynévsor-programot az iskola.db adatbázissal:

  1. Az adatbázis az 1. szakasz szerint (két tábla, hét diák); a kapcsolati szöveg a Foreign Keys=True beállítással, az adatbázis a program mellé másolva.
  2. A DB osztály Osztalyok, Diakok és DiakFelvetel metódusa; az ablak a szűrővel, a táblázattal és a felvételi résszel.
  3. Új osztály diákokkal: egy többsoros txtDiakok mezőbe soronként a diákok nevét írja a felhasználó, egy txtOsztalyNev mezőbe az osztály nevét. A btnOsztalyLetrehoz gomb a tranzakciós OsztalyDiakokkal metódust hívja (az üres sorokat kihagyva), majd frissíti a listákat. Már létező osztálynévnél érthető üzenet jelenjen meg.
  4. Osztály törlése: egy btnOsztalyTorol gomb törölje a szűrőben kiválasztott osztályt, de csak ha nincs diákja. Ha van, mutasson érthető üzenetet (a FOREIGN KEY hibát kezelve).
  5. Teszteld: nem létező osztályazonosító (Foreign Keys kikapcsolva és bekapcsolva), azonos nevű osztály, üres névsor, törlés diákokkal és diák nélkül.
Megoldás megnyitása (próbáld ki előbb önállóan!)

A tranzakciós metódus a leckében szerepel. Az ablak kódja a névsort bontja sorokra, az üreseket kihagyva, és a kivételeket külön kezeli: az SQLiteException üzenetében szereplő UNIQUE és FOREIGN KEY szövegek alapján érthető üzenetet ad.

Form1.cs, DB.csC#
// DB.cs: osztály törlése (diák nélkül)
public static void OsztalyTorles(int id)
{
using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteCommand parancs = new SQLiteCommand("DELETE FROM osztalyok WHERE id = @id", kapcsolat))
{
parancs.Parameters.AddWithValue("@id", id);
parancs.ExecuteNonQuery();
}
}
}

// Form1.cs
private void btnOsztalyLetrehoz_Click(object sender, EventArgs e)
{
string osztalyNev = txtOsztalyNev.Text.Trim();

if (osztalyNev.Length == 0)
{
lblUzenet.Text = "Add meg az osztály nevét!";
return;
}

// az üres sorok kihagyása
int db = 0;

foreach (string sor in txtDiakok.Lines)
{
if (sor.Trim().Length > 0)
{
db = db + 1;
}
}

string[] nevek = new string[db];
int i = 0;

foreach (string sor in txtDiakok.Lines)
{
if (sor.Trim().Length > 0)
{
nevek[i] = sor.Trim();
i = i + 1;
}
}

try
{
DB.OsztalyDiakokkal(osztalyNev, nevek);
lblUzenet.Text = $"Az osztály létrejött {db} diákkal.";
// a két ComboBox és a lista újratöltése a Form1_Load logikájával
}
catch (SQLiteException ex)
{
if (ex.Message.Contains("UNIQUE"))
{
lblUzenet.Text = "Ilyen nevű osztály már létezik.";
}
else
{
DB.Naploz(ex);
lblUzenet.Text = "A létrehozás nem sikerült.";
}
}
}

private void btnOsztalyTorol_Click(object sender, EventArgs e)
{
int id = Convert.ToInt32(cmbSzuro.SelectedValue);

if (id == 0)
{
lblUzenet.Text = "Válassz ki egy osztályt a szűrőben!";
return;
}

try
{
DB.OsztalyTorles(id);
lblUzenet.Text = "Az osztály törölve.";
}
catch (SQLiteException ex)
{
if (ex.Message.Contains("FOREIGN KEY"))
{
lblUzenet.Text = "Az osztályt nem lehet törölni, amíg diákjai vannak.";
}
else
{
DB.Naploz(ex);
lblUzenet.Text = "A törlés nem sikerült.";
}
}
}
📷 Képernyőkép helye

Mit tegyél ide: a kész program a létrehozás részével (osztály neve, diákok névsora egy többsoros mezőben) és a törlés gombbal.Javasolt fájlnév: winforms-3-10-kesz.webp · Alt szöveg: Az osztálynévsor-program osztálylétrehozással

Három önálló gyakorlófeladat

Egyszerre csak egyet csinálj: tervezd meg, írd meg, futtasd, ellenőrizd, és csak utána nyisd ki a megoldást.

1. Létszám osztályonként

Egy gomb írja az lstLetszam ListBoxba osztályonként a diákok számát (10.A: 2), az üres osztályokat is mutatva (11.B: 0).

Egy lehetséges megoldás

A LEFT JOIN megtartja az üres osztályokat, a COUNT(d.id) pedig csak a létező diákokat számolja (nem COUNT(*)). Az olvasót (ExecuteReader) a 3.9 leckéből ismered.

Form1.csC#
lstLetszam.Items.Clear();

using (SQLiteConnection kapcsolat = DB.UjKapcsolat())
{
kapcsolat.Open();

string sql = "SELECT o.nev AS osztaly, COUNT(d.id) AS letszam FROM osztalyok o LEFT JOIN diakok d ON d.osztalyid = o.id GROUP BY o.id, o.nev ORDER BY o.nev";

using (SQLiteCommand parancs = new SQLiteCommand(sql, kapcsolat))
using (SQLiteDataReader olvaso = parancs.ExecuteReader())
{
while (olvaso.Read())
{
lstLetszam.Items.Add(olvaso["osztaly"] + ": " + olvaso["letszam"]);
}
}
}

2. Diákok áthelyezése és az üres osztály törlése

A btnAthelyez gomb helyezze át az egyik osztály összes diákját egy másik osztályba, majd törölje a most már üres osztályt. A két lépés együtt sikerüljön vagy együtt bukjon.

Egy lehetséges megoldás

Két művelet, amely együtt egy egységet alkot: tranzakció. Ha az osztály törlése valamiért nem sikerül, az áthelyezés is visszavonódik. Mindkét parancs ugyanazt a kapcsolatot és tranzakciót használja.

DB.csC#
public static void OsztalyAthelyezes(int forrasId, int celId)
{
using (SQLiteConnection kapcsolat = UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteTransaction tranzakcio = kapcsolat.BeginTransaction())
{
using (SQLiteCommand athelyez = new SQLiteCommand("UPDATE diakok SET osztalyid = @cel WHERE osztalyid = @forras", kapcsolat, tranzakcio))
{
athelyez.Parameters.AddWithValue("@cel", celId);
athelyez.Parameters.AddWithValue("@forras", forrasId);
athelyez.ExecuteNonQuery();
}

using (SQLiteCommand torol = new SQLiteCommand("DELETE FROM osztalyok WHERE id = @forras", kapcsolat, tranzakcio))
{
torol.Parameters.AddWithValue("@forras", forrasId);
torol.ExecuteNonQuery();
}

tranzakcio.Commit();
}
}
}

3. ON DELETE SET NULL

Hozz létre az iskola2.db adatbázisban ugyanilyen két táblát, de az idegen kulcs legyen ON DELETE SET NULL. Töröld a 10.B osztályt, és nézd meg, mi történik a diákjaival. Mikor jobb ez, mint a RESTRICT viselkedés?

Egy lehetséges megoldás

A törlés sikerül, a 10.B diákjainak osztalyid értéke NULL lesz (a diákok megmaradnak, csak osztály nélkül). Hasznos, ha a hivatkozott sor megszűnése nem jelenti a hivatkozók megszűnését (például egy osztály megszűnik, de a diákok az iskolában maradnak). Ahol a hivatkozott sor nélkül a hivatkozó értelmetlen (például egy megrendelés sorai megrendelés nélkül), a CASCADE vagy a tiltás (RESTRICT) a helyes.

SQLSQLite
CREATE TABLE diakok (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nev TEXT NOT NULL,
osztalyid INTEGER,
FOREIGN KEY (osztalyid) REFERENCES osztalyok (id) ON DELETE SET NULL
);

DELETE FROM osztalyok WHERE id = 2;
SELECT * FROM diakok; -- a korábbi 10.B diákok osztalyid oszlopa NULL

Gyakorlótár: 6 további feladat

Három szinten gyakorolhatsz: alap, közepes és kihívás. Az első négy a DB Browser Execute SQL lapján oldható meg az iskola.db adatbázissal, az utolsó kettő C# feladat. Csak a próbálkozás után nyisd le a megoldást.

Alap szint

1. Új tábla kapcsolattal

Hozz létre egy tantargyak táblát (id, nev), és egy jegyek táblát (id, diakid, tantargyid, jegy 1–5 közötti számmal). A jegyek két idegen kulcsot tartalmazzon.

Megoldás megnyitása

Egy tábla több idegen kulcsot is tartalmazhat. A CHECK kikötés az érték tartományát korlátozza: az adatbázis 1–5 közötti jegyet enged csak.

SQLSQLite
CREATE TABLE tantargyak (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nev TEXT NOT NULL UNIQUE
);

CREATE TABLE jegyek (
id INTEGER PRIMARY KEY AUTOINCREMENT,
diakid INTEGER NOT NULL,
tantargyid INTEGER NOT NULL,
jegy INTEGER NOT NULL CHECK (jegy BETWEEN 1 AND 5),
FOREIGN KEY (diakid) REFERENCES diakok (id),
FOREIGN KEY (tantargyid) REFERENCES tantargyak (id)
);

2. Diákok az osztályukkal

Írj lekérdezést, amely a diákok nevét és az osztályuk nevét mutatja osztály, azon belül név szerint rendezve (csak a osztállyal rendelkezők).

Megoldás megnyitása

Az INNER JOIN a osztály nélküli diákot kihagyja. A d és o rövid név teszi egyértelművé az oszlopokat.

SQLSQLite
SELECT d.nev AS diak, o.nev AS osztaly
FROM diakok d
INNER JOIN osztalyok o ON d.osztalyid = o.id
ORDER BY o.nev, d.nev;

Közepes szint

3. Kik nincsenek osztályban?

Add meg a diákok nevét, akiknek nincs osztályuk, két módon: egy LEFT JOIN és egy egyszerű WHERE segítségével.

Megoldás megnyitása

A kérdés megoldható egyetlen táblából is, mert az osztalyid maga NULL. A LEFT JOIN változat ugyanazt adja, de a JOIN ilyenkor felesleges. Hasznos akkor lenne, ha az osztályok adataira is szükség van.

SQLSQLite
SELECT nev FROM diakok WHERE osztalyid IS NULL;

SELECT d.nev
FROM diakok d
LEFT JOIN osztalyok o ON d.osztalyid = o.id
WHERE o.id IS NULL;

4. A legnagyobb osztály

Add meg a legtöbb diákot tartalmazó osztály nevét és létszámát.

Megoldás megnyitása

Osztályonként összeszámoljuk a diákokat (GROUP BY), csökkenő létszám szerint rendezzük, és az első sort kérjük (LIMIT 1). Egyenlőség esetén csak az egyik osztályt kapod meg.

SQLSQLite
SELECT o.nev AS osztaly, COUNT(d.id) AS letszam
FROM osztalyok o
LEFT JOIN diakok d ON d.osztalyid = o.id
GROUP BY o.id, o.nev
ORDER BY letszam DESC
LIMIT 1;

Kihívás szint

5. A ComboBox kezdőértéke és a kiválasztás

Írd meg, hogy a cmbOsztaly egy diák szerkesztésekor a diák saját osztályát mutassa kiválasztva, osztály nélküli diáknál pedig az első, (nincs) elemet. Hogyan állítod be programból a kiválasztott értéket?

Megoldás megnyitása

A SelectedValue tulajdonság értéke beállítható: az adott azonosítójú elem lesz kiválasztva. Ha az azonosító nem szerepel a listában, nincs kiválasztott elem (SelectedIndex: -1), ezért érdemes kezelni. Az osztály nélküli diák azonosítója az (összes) elemmel megegyező 0 kell legyen, hogy a lista első eleme legyen kiválasztva.

Form1.csC#
int osztalyId = 0;   // 0: nincs osztálya

if (!sor.IsNull("osztalyid"))
{
osztalyId = Convert.ToInt32(sor["osztalyid"]);
}

cmbOsztaly.SelectedValue = osztalyId;

if (cmbOsztaly.SelectedIndex < 0)
{
cmbOsztaly.SelectedIndex = 0; // ismeretlen azonosító: az első elem
}

6. A tranzakció visszavonásának próbája

Írj egy gombot, amely egy tranzakcióban két diákot vesz fel, de a második után szándékosan kivételt dob (throw new Exception("próba")). Ellenőrizd a DB Browserben, hogy egyik diák sem került be. Mi történik, ha a kivétel dobása elé teszed a Commit hívást?

Megoldás megnyitása

A Commit előtti kivétel esetén a tranzakció visszavonódik, egyik diák sem marad bent. Ha a Commit a kivétel előtt fut le, az összes diák véglegesen megmarad, mert a véglegesítés megtörtént. Ez mutatja, hogy a Commit helye dönti el, mit véd a tranzakció.

Form1.csC#
private void btnProba_Click(object sender, EventArgs e)
{
try
{
using (SQLiteConnection kapcsolat = DB.UjKapcsolat())
{
kapcsolat.Open();

using (SQLiteTransaction tranzakcio = kapcsolat.BeginTransaction())
{
using (SQLiteCommand parancs = new SQLiteCommand("INSERT INTO diakok (nev) VALUES (@nev)", kapcsolat, tranzakcio))
{
parancs.Parameters.AddWithValue("@nev", "Próba Egy");
parancs.ExecuteNonQuery();

parancs.Parameters["@nev"].Value = "Próba Kettő";
parancs.ExecuteNonQuery();
}

throw new Exception("próba"); // a Commit előtt

tranzakcio.Commit();
}
}
}
catch (Exception ex)
{
lblUzenet.Text = "A művelet megszakadt: " + ex.Message + " (a két diák nem került be)";
}
}

Gyors önellenőrzés

Válaszolj fejben, és csak utána nyisd le a választ.

1. Mi az idegen kulcs?

Egy oszlop, amely egy másik tábla elsődleges kulcsára hivatkozik, és így összekapcsolja a két tábla sorait.

2. Mit kell tenni, hogy az SQLite ellenőrizze az idegen kulcsot?

Kapcsolatonként be kell kapcsolni: a C# kapcsolati szövegben Foreign Keys=True;.

3. Mi a különbség az INNER JOIN és a LEFT JOIN között?

Az INNER JOIN csak a mindkét oldalon párt találó sorokat adja. A LEFT JOIN a bal oldali tábla minden sorát megtartja, a hiányzó párt NULL értékkel tölti ki.

4. Mire való a DisplayMember és a ValueMember?

A DisplayMember az oszlop, amelyet a felhasználó lát, a ValueMember az, amelyet a program kap a SelectedValue tulajdonságból (például az azonosító).

5. Mi a tranzakció, és mikor kell?

Több művelet oszthatatlan egysége: vagy mind sikerül (Commit), vagy egyik sem (Rollback). Akkor kell, ha több összefüggő módosítás együtt értelmes.

6. Mi történik, ha a using blokkban elmarad a Commit?

A blokk elhagyásakor a tranzakció visszavonódik, és az összes módosítás elvész, hibaüzenet nélkül.

7. Mi a baj, ha a tranzakció alatt másik kapcsolatot nyitsz az írásra?

A másik kapcsolat nem része a tranzakciónak, és az SQLite database is locked hibát adhat. A tranzakcióban egy kapcsolatot használj.

Mit tudsz már?

  • Két táblát kapcsolsz össze elsődleges és idegen kulccsal, és bekapcsolod az ellenőrzést.
  • JOIN lekérdezéssel olvashatóvá teszed a kapcsolt adatot.
  • A ComboBox DisplayMember/ValueMember párosával nevet mutatsz, azonosítót kezelsz.
  • Tranzakcióba foglalod az összetartozó műveleteket.
  • Érted a kulcssértés és a visszavonás hibaüzeneteit.

Következő lecke: záró projekt – nyilvántartás

A 3. fejezet eszközei mind megvannak. A záró projektben egy teljes nyilvántartást építesz fel: több tábla, kereső, szerkesztés, védelem és hibakezelés.