Zum Inhalt
wannacode

Datenbanken

Kurz gesagt: Eine Datenbank speichert Daten in Tabellen und findet sie mit einer SQL-Abfrage schnell; in C# sprichst du über ADO.NET mit ihr, und Parameter schützen vor SQL-Injection.

Problem

Ein Katalog in einer Datei

Der Katalog des Shops ist eine CSV-Datei, wie im Modul über Dateien. Solange es hundert Produkte sind, geht das. Dann werden es hunderttausend, und es gibt Probleme:

  • um ein Produkt zu finden, liest du die ganze Datei;
  • „alles unter 5 €, nach Preis sortiert“ ist eine Schleife, ein if und eine Sortierung, alles von Hand geschrieben;
  • zwei Kassen schreiben gleichzeitig in die Datei, und ein Eintrag geht verloren oder die Datei wird beschädigt;
  • mitten beim Schreiben fällt der Strom aus, und die halbe Datei ist weg.

Deshalb liegen die Daten fast jedes echten Programms in einer Datenbank: einem Programm, dessen Aufgabe es ist, Daten aufzubewahren, sie schnell zu finden und nie zu verlieren.

Die Idee

Tabellen und SQL

Eine relationale Datenbank speichert Daten in Tabellen. Eine Tabelle hat Spalten (Name, Typ) und Zeilen (eine für jedes Produkt). Die Spalte id ist der Primärschlüssel: Ihr Wert ist in jeder Zeile anders, deshalb findet man mit ihm genau eine Zeile.

Mit der Datenbank sprichst du in SQL. Die fünf häufigsten Befehle:

SQL
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
price DECIMAL(8, 2)
);
INSERT INTO products VALUES (1, 'Kaffee', 7.50);
SELECT name, price FROM products WHERE price < 5 ORDER BY price;
UPDATE products SET price = 6.90 WHERE id = 1;
DELETE FROM products WHERE id = 3;
  • CREATE TABLE legt eine Tabelle an, INSERT fügt eine Zeile ein, UPDATE ändert Zeilen und DELETE löscht sie.
  • SELECT liest: welche Spalten, aus welcher Tabelle, unter welcher Bedingung (WHERE) und in welcher Reihenfolge (ORDER BY).

Du sagst, was du willst, und die Datenbank entscheidet, wie sie es schnell findet.

Genauer, für Neugierige
  • Bekannte relationale Datenbanken sind PostgreSQL, MySQL, Oracle und SQL Server. Sie laufen als eigene Server. Hier benutzen wir SQLite: eine kleine Datenbank, die in einer Datei oder ganz im Speicher des Programms lebt. In .NET kommt sie mit Microsofts Paket Microsoft.Data.Sqlite (dotnet add package Microsoft.Data.Sqlite). Die Adresse Data Source=:memory: ist eine Datenbank im Speicher: Sie lebt, solange die Verbindung offen ist, und jeder Lauf beginnt mit einer leeren.
  • Text steht in SQL in einfachen Anführungszeichen: 'Kaffee'. In PostgreSQL, MySQL und SQL Server ist DECIMAL(8, 2) eine exakte Zahl mit 2 Nachkommastellen, das passt zu Geld. SQLite speichert keine exakten Dezimalzahlen: Dort ist 7.50 eine gewöhnliche Gleitkommazahl.

Sieh es dir an

ADO.NET: Verbindung, Befehl, Ergebnis

ADO.NET ist der Weg, auf dem .NET mit relationalen Datenbanken spricht. Jede Datenbank hat ihr eigenes Paket mit denselben Teilen: Die Verbindung ist eine SqliteConnection, die Abfrage ein SqliteCommand, und das Ergebnis liest du mit einem SqliteDataReader (für SQL Server heißen sie SqlConnection, SqlCommand und SqlDataReader). Die Methode Setup legt die Tabelle an und fügt drei Produkte ein:
static void Setup(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE products (id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2))";
cmd.ExecuteNonQuery();
cmd.CommandText = "INSERT INTO products VALUES (1, 'Kaffee', 7.50), (2, 'Tee', 3.20), (3, 'Schokolade', 2.40)";
cmd.ExecuteNonQuery();
}
Wir lesen die Zeilen und machen aus jeder ein Objekt von record Product(int Id, string Name, double Price):
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
Setup(conn);
var products = new List<Product>();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, price FROM products ORDER BY price";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
products.Add(new Product(reader.GetInt32(0), reader.GetString(1), reader.GetDouble(2)));
}
foreach (var p in products)
{
Console.WriteLine(p);
}
Ausgabe
Product { Id = 3, Name = Schokolade, Price = 2.4 }
Product { Id = 2, Name = Tee, Price = 3.2 }
Product { Id = 1, Name = Kaffee, Price = 7.5 }
  • new SqliteConnection(address) erzeugt die Verbindung, und Open() öffnet sie. Die Adresse (der Connection-String) sagt, welche Datenbank: hier SQLite im Speicher, oder eine Datei wie Data Source=shop.db.
  • conn.CreateCommand() erzeugt einen Befehl, und CommandText ist sein SQL. ExecuteNonQuery() ist für Befehle, die etwas ändern (CREATE, INSERT, UPDATE, DELETE), und gibt zurück, wie viele Zeilen sich geändert haben. ExecuteReader() ist für SELECT.
  • reader.Read() geht zur nächsten Zeile und gibt false zurück, wenn die Zeilen zu Ende sind. GetInt32, GetString und GetDouble holen eine Spalte nach ihrer Nummer: ab 0, in der Reihenfolge des SELECT. Nach dem Namen geht es mit reader.GetOrdinal("name").
  • Die Verbindung, der Befehl und der Reader müssen geschlossen werden: using var schließt sie (es ruft Dispose() auf), wenn der Block endet.
  • Fehler sind Exceptions vom Typ SqliteException. C# hat keine geprüften Exceptions, deshalb schreiben die Methoden kein throws.

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
Setup(conn);
var products = new List<Product>();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id, name, price FROM products ORDER BY price";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
products.Add(new Product(reader.GetInt32(0), reader.GetString(1), reader.GetDouble(2)));
}
foreach (var p in products)
{
Console.WriteLine(p);
}
Schritt für Schritt, in Worten
  1. Eine Verbindung zur Datenbank im Speicher. Setup legt die Tabelle products an und fügt 3 Zeilen ein.
  2. Eine leere Liste, in die die Objekte kommen.
  3. SELECT mit ORDER BY price: ExecuteReader gibt einen Reader über die Zeilen zurück, die günstigste zuerst.
  4. reader.Read() geht zu Zeile 1. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Schokolade.
  5. reader.Read() geht zu Zeile 2. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Tee.
  6. reader.Read() geht zu Zeile 3. GetInt32, GetString und GetDouble holen die Spalten nach Nummer, und new Product macht daraus ein Objekt: Kaffee.
  7. reader.Read() gibt false zurück: Die Zeilen sind zu Ende, und die Schleife hört auf.
  8. Die Liste enthält gewöhnliche C#-Objekte. Die Ausgabe zeigt den Record mit seinen Feldern.
Genauer, für Neugierige
  • Bei einer echten Datenbank auf einem Server ist die Adresse anders, und es gibt einen Benutzer und ein Passwort. Außerdem brauchst du den Treiber der Datenbank als Abhängigkeit im Projekt (für MySQL: das Paket MySqlConnector, mit MySqlConnection statt SqliteConnection):
var address = "Server=localhost;Port=3306;Database=shop;User ID=shop_app;Password=geheim";
using var conn = new MySqlConnection(address);
conn.Open();
Console.WriteLine("Mit der Datenbank verbunden!");
  • Das Passwort steht nicht im Code. Es wird aus den Einstellungen oder aus einer Umgebungsvariable gelesen (appsettings.json oder Environment.GetEnvironmentVariable).
  • Die Preise sind hier double, damit es kurz bleibt. In einem echten Shop ist Geld ein decimal: Eine DECIMAL-Spalte in PostgreSQL oder SQL Server liest man mit reader.GetDecimal(2). SQLite speichert keine exakten Dezimalzahlen, deshalb wird Geld dort in ganzen Cent gespeichert.

Probier es aus

Was wird ausgegeben?

Die Produkte sind Kaffee 7,50 €, Tee 3,20 € und Schokolade 2,40 €. Was gibt das Programm aus?

using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
Setup(conn);
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM products WHERE price < 5 ORDER BY price DESC";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
Console.WriteLine(reader.GetString(0));
}
Antwort

Problem

Die Suche, die alles zeigt

Die Website sucht ein Produkt nach dem Namen, den der Nutzer eintippt. Die Abfrage wird mit $"…{name}…" zusammengesetzt:

static void Search(SqliteConnection conn, string name)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT name, price FROM products WHERE name = '{name}'";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($" {reader.GetString(0)}: {reader.GetDouble(1)} €");
}
}
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
Setup(conn);
Console.WriteLine("Suche: Tee");
Search(conn, "Tee");
Console.WriteLine("Suche: x' OR '1'='1");
Search(conn, "x' OR '1'='1");
Ausgabe
Suche: Tee
Tee: 3.2 €
Suche: x' OR '1'='1
Kaffee: 7.5 €
Tee: 3.2 €
Schokolade: 2.4 €

Mit „Tee“ funktioniert es. Mit x' OR '1'='1 wird die Abfrage zu ... WHERE name = 'x' OR '1'='1'. Die Bedingung ist immer wahr, und die ganze Tabelle kommt heraus. Die Eingabe des Nutzers ist Teil des SQL geworden. Das ist SQL-Injection, einer der häufigsten Angriffe auf Websites. Mit demselben Trick kann sich jemand ohne Passwort anmelden oder eine Tabelle löschen.

Die Idee

Parameter: $name

Die Lösung ist, die Abfrage und die Werte getrennt zu schicken. Die Abfrage hat einen benannten Parameter, $name, an der Stelle des Werts, und den Wert übergibst du mit cmd.Parameters.AddWithValue("$name", name):
static void Search(SqliteConnection conn, string name)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name, price FROM products WHERE name = $name";
cmd.Parameters.AddWithValue("$name", name);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($" {reader.GetString(0)}: {reader.GetDouble(1)} €");
}
}
Ausgabe
Suche: Tee
Tee: 3.2 €
Suche: x' OR '1'='1

Jetzt ist x' OR '1'='1 nur Text, der als Name gesucht wird. Ein solches Produkt gibt es nicht, und nichts kommt heraus. Was der Nutzer auch eintippt, es kann nicht zu SQL werden.

Die Regel ist einfach: Ein Wert vom Nutzer, aus einer Datei oder aus dem Netzwerk wird nie in SQL hineingeklebt. Immer ein Parameter. Microsoft.Data.Sqlite akzeptiert die Namen $name, @name und :name. In SQL Server und in den meisten Beispielen für .NET siehst du @name.

Probier es aus

Kaffee mit Apostroph

Die Methode benennt ein Produkt um. Mit dem Namen Kaffee 'Arabica' bricht das Programm mit SqliteException: SQLite Error 1: 'near "Arabica": syntax error'. ab. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Transaktion: alles oder nichts

Maria überweist 150 € an Jonas. Das sind zwei Abfragen: bei Jonas hinzufügen, bei Maria abziehen. Die Tabelle hat die Bedingung CHECK (balance >= 0), deshalb schlägt die zweite Abfrage fehl: Maria hat nur 100 €.

static void OpenAccounts(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE TABLE accounts (id INT PRIMARY KEY, owner VARCHAR(50), balance INT CONSTRAINT no_debt CHECK (balance >= 0))";
cmd.ExecuteNonQuery();
cmd.CommandText = "INSERT INTO accounts VALUES (1, 'Maria', 100), (2, 'Jonas', 50)";
cmd.ExecuteNonQuery();
}
static void Move(SqliteConnection conn, int id, int amount)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE accounts SET balance = balance + $amount WHERE id = $id";
cmd.Parameters.AddWithValue("$amount", amount);
cmd.Parameters.AddWithValue("$id", id);
cmd.ExecuteNonQuery();
}
static void PrintBalances(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT owner, balance FROM accounts ORDER BY id";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($" {reader.GetString(0)}: {reader.GetInt32(1)} €");
}
}
static void Transfer(SqliteConnection conn, int from, int to, int amount)
{
Move(conn, to, amount);
Move(conn, from, -amount);
}
using var conn = new SqliteConnection("Data Source=:memory:");
conn.Open();
OpenAccounts(conn);
try
{
Transfer(conn, 1, 2, 150);
}
catch (SqliteException)
{
Console.WriteLine("Die Überweisung von 150 € wurde abgelehnt.");
}
PrintBalances(conn);
Ausgabe
Die Überweisung von 150 € wurde abgelehnt.
Maria: 100 €
Jonas: 200 €
Die Überweisung wurde abgelehnt, aber Jonas hat schon 150 € aus dem Nichts bekommen. Standardmäßig wird jede Abfrage sofort gespeichert. Die zwei Abfragen müssen eine Transaktion sein: Entweder gehen beide durch, oder keine.
static void Transfer(SqliteConnection conn, int from, int to, int amount)
{
using var tx = conn.BeginTransaction();
try
{
Move(conn, to, amount);
Move(conn, from, -amount);
tx.Commit();
}
catch (SqliteException)
{
tx.Rollback();
throw;
}
}
Ausgabe
Die Überweisung von 150 € wurde abgelehnt.
Maria: 100 €
Jonas: 50 €
  • conn.BeginTransaction() beginnt eine Transaktion: Die Änderungen sind noch nicht endgültig. Die Befehle, die du danach mit CreateCommand() erzeugst, sind in ihr.
  • tx.Commit() speichert sie alle auf einmal.
  • tx.Rollback() macht sie alle rückgängig, wenn etwas fehlschlägt. throw; schickt dieselbe Exception weiter nach oben, damit auch der Aufrufer davon erfährt.
  • using var tx ist ein Sicherheitsnetz: Wenn die Transaktion das Ende des Blocks ohne Commit() erreicht, macht Dispose() sie rückgängig.
Genauer, für Neugierige
  • Die Exception für die fehlgeschlagene Abfrage ist eine SqliteException mit der Meldung SQLite Error 19: 'CHECK constraint failed: no_debt'. 19 ist der Code von SQLite für eine verletzte Regel (e.SqliteErrorCode). no_debt ist der Name der Bedingung: In der Tabelle steht balance INT CONSTRAINT no_debt CHECK (balance >= 0).
  • Datenbanken versprechen bei Transaktionen vier Dinge, bekannt als ACID: alles oder nichts (Atomarität), die Daten bleiben nach den Regeln der Tabelle korrekt (Konsistenz), gleichzeitig laufende Transaktionen stören sich nicht gegenseitig (Isolation), und was gespeichert ist, geht nicht verloren, auch wenn der Strom ausfällt (Dauerhaftigkeit).

Die Idee

ORM: Objekte statt Zeilen

Mit ADO.NET schreibst du für jede Klasse SQL und machst die Zeilen von Hand zu Objekten. Ein ORM (objektrelationale Abbildung, object-relational mapping) nimmt dir das ab: Du beschreibst die Klasse, und die Bibliothek macht die Tabelle, die Abfragen und die Objekte selbst. In .NET ist das Microsofts Entity Framework Core (EF Core), für SQLite mit dem Paket Microsoft.EntityFrameworkCore.Sqlite. Ein anderes bekanntes ist Dapper: Es ist leichter, du schreibst das SQL selbst, und es macht die Objekte.
Models.cs
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
[Table("products")]
class Product
{
public int Id { get; set; }
[Required, MaxLength(50)]
public string Name { get; set; }
public double Price { get; set; }
}
class ShopContext : DbContext
{
public DbSet<Product> Products { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=:memory:");
}
}
  • [Table("products")] gibt den Namen der Tabelle an. Die Dinge in [...] sind Attribute: Notizen an der Klasse und ihren Eigenschaften, die EF Core liest. Du siehst sie im Modul über das Web wieder.
  • Die Eigenschaft Id ist nach Konvention der Primärschlüssel (der Name ist Id oder ProductId), und die Datenbank gibt ihr einen Wert.
  • [Required] ist NOT NULL, und [MaxLength(50)] gibt die Länge an. Eigenschaften ohne Attribut werden auch zu Spalten.
  • Der DbContext ist die Verbindung zur Datenbank. Jedes DbSet<Product> ist eine Tabelle, und OnConfiguring sagt, welche Datenbank es ist: hier SQLite im Speicher.

Speichern und Lesen gehen über den Kontext:

using var db = new ShopContext();
db.Database.OpenConnection();
db.Database.EnsureCreated();
db.Products.Add(new Product { Name = "Kaffee", Price = 7.50 });
db.Products.Add(new Product { Name = "Tee", Price = 3.20 });
db.SaveChanges();
var cheap = db.Products
.Where(p => p.Price < 5)
.OrderBy(p => p.Price)
.ToList();
foreach (var p in cheap)
{
Console.WriteLine($"{p.Name}: {p.Price} €");
}
Ausgabe
Tee: 3.2 €

EnsureCreated() legt die Tabellen an. Add fügt ein Objekt hinzu, und SaveChanges() speichert alle hinzugefügten und geänderten Objekte auf einmal, in einer Transaktion. Die Abfrage ist LINQ, wie bei Listen, aber EF Core geht nicht durch Objekte im Speicher: Es macht aus Where(p => p.Price < 5) SQL, das von der Klasse und ihrer Eigenschaft Price spricht, und schickt es an die Datenbank. ToList() führt die Abfrage aus und gibt fertige Objekte zurück.

Die Datenbank im Speicher lebt, solange die Verbindung offen ist, und EF Core öffnet und schließt sie sonst für jede Abfrage. Deshalb hält OpenConnection() sie offen. Mit einer Datei (Data Source=shop.db) brauchst du diese Zeile nicht.

Hier auf der Website läuft dieser Code: EF Core 10 mit SQLite ist in der Prüfung vorhanden. Die Ausgabe kommt von dort.

Genauer, für Neugierige
  • Eine LINQ-Abfrage ist Code, deshalb lassen sich die Bedingungen leicht aus der Auswahl des Nutzers zusammensetzen. Eine Methode, die nach dem Namen sucht:
static List<Product> Named(ShopContext db, string name)
{
return db.Products.Where(p => p.Name == name).ToList();
}
  • Wenn du genau dein eigenes SQL brauchst, führt FromSql es aus und gibt Objekte zurück. Der Wert in {max} wird nicht hineingeklebt: FromSql schickt ihn als Parameter, deshalb gibt es hier keine Injection. FromSqlRaw ist anders: Es nimmt fertigen Text, und wenn du ihn mit + oder $"…" baust, ist der Wert wieder hineingeklebt.
static List<Product> Cheaper(ShopContext db, double max)
{
return db.Products.FromSql($"SELECT * FROM products WHERE price < {max}").ToList();
}
  • Eine Transaktion, die bei einem Fehler rückgängig gemacht wird, wenn eine Änderung aus mehreren Aufrufen von SaveChanges besteht:
using var tx = db.Database.BeginTransaction();
try
{
db.Products.Add(new Product { Name = "Schokolade", Price = 2.40 });
db.SaveChanges();
// weitere Änderungen an den Objekten und SaveChanges
tx.Commit();
}
catch
{
tx.Rollback();
throw;
}
  • ToQueryString() zeigt das SQL der Abfrage, ohne sie auszuführen. So siehst du, dass darunter weiterhin SQL steckt und max ein Parameter ist:
var max = 5.0;
var query = db.Products.Where(p => p.Price < max).OrderBy(p => p.Price);
Console.WriteLine(query.ToQueryString());
Ausgabe
.param set @max 5.0
SELECT "p"."Id", "p"."Name", "p"."Price"
FROM "products" AS "p"
WHERE "p"."Price" < @max
ORDER BY "p"."Price"
  • In älteren Projekten mit .NET Framework siehst du Entity Framework 6 (System.Data.Entity). EF Core ist das neue, für .NET 5 und später, und die Ideen sind dieselben.

Übung

Ein Shop mit Datenbank

Löse die Aufgaben in deinem Editor, mit dem Paket Microsoft.Data.Sqlite im Projekt und der Datenbank Data Source=:memory:.1. Katalog. Lege eine Tabelle products an und die Methoden Add, SetPrice, Remove und List, alle außer List mit Parametern ($id, $name, $price). Füge Kaffee (7,50 €), Tee (3,20 €) und Schokolade (2,40 €) hinzu, gib sie aus, ändere den Preis des Kaffees auf 6,90 €, lösche die Schokolade und gib sie wieder aus:
Ausgabe
1. Kaffee: 7.5 €
2. Tee: 3.2 €
3. Schokolade: 2.4 €
---
1. Kaffee: 6.9 €
2. Tee: 3.2 €
2. Suche nach einem Teil des Namens. Die Produkte sind Kaffee, Kakao, Tee und Éclair. Eine Methode Find(conn, text) findet alle Produkte, deren Name den Text enthält, ohne auf Groß- und Kleinschreibung zu achten. LOWER ändert in SQLite nur die Buchstaben A bis Z, deshalb bleibt É, wie es ist. Gib der Datenbank eine eigene Funktion: conn.CreateFunction("lower_text", (string text) => text.ToLowerInvariant()), dann WHERE lower_text(name) LIKE $text mit dem Wert "%" + text.ToLowerInvariant() + "%". Probiere „KA“, „éclair“ und einen Injection-Versuch:
Ausgabe
Suche nach „KA“:
Kaffee: 7.5 €
Kakao: 4.8 €
Suche nach „éclair“:
Éclair: 2.4 €
Suche nach „' OR '1'='1“:
keine Produkte gefunden
3. Eine Überweisung, die mittendrin fehlschlägt. Die Konten sind Maria (100 €) und Jonas (50 €). Transfer(conn, from, to, amount) zieht zuerst von from ab und fügt dann bei to hinzu. Wenn ExecuteNonQuery() 0 zurückgibt (kein solches Konto), wirf InvalidOperationException("Kein Konto " + id). Alles ist in einer Transaktion aus BeginTransaction(). Überweise 30 € von 1 an 2, dann 40 € von 1 an das Konto 99, das es nicht gibt:
Ausgabe
Überwiesen: 30 € von 1 an 2
Abgelehnt: Kein Konto 99
Maria: 70 €
Jonas: 80 €

Die zweite Überweisung hat Maria 40 € abgezogen, bevor sie fehlschlug, aber Rollback() hat die 40 € zurückgebucht.

Test

Teste dich

1. Was gibt ExecuteReader zurück?
2. Warum ist $"... WHERE name = '{name}'" gefährlich?
3. Eine Überweisung besteht aus zwei Abfragen. Die erste gelingt, die zweite schlägt fehl. Was macht tx.Rollback()?
4. Was macht ein ORM wie Entity Framework Core?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·