Databases
In short: A database keeps data in tables and finds it fast with an SQL query; in C# you talk to it through ADO.NET, and parameters protect against SQL injection.
Problem
A catalog in a file
The store's catalog is a CSV file, as in the files module. While there are a hundred products, it works. Then there are a hundred thousand, and problems show up:
- to find one product, you read the whole file;
- "everything under €5, sorted by price" is a loop, an
ifand a sort, written by hand; - two cash registers write to the file at the same time, and one record gets lost or the file gets damaged;
- the power goes out in the middle of a write, and half the file is gone.
That's why the data of almost every real program lives in a database: a program whose job is to keep data, find it fast and never lose it.
The idea
Tables and SQL
A relational database keeps data in tables. A table has columns (name, type) and rows (one for each product). The id column is the primary key: its value is different for each row, so it finds exactly one row.
You talk to the database in SQL. The five most common commands:
CREATE TABLE products ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, price DECIMAL(8, 2));
INSERT INTO products VALUES (1, 'Coffee', 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 TABLEmakes a table,INSERTadds a row,UPDATEchanges rows andDELETEremoves them.SELECTreads: which columns, from which table, under what condition (WHERE) and in what order (ORDER BY).
You say what you want, and the database decides how to find it fast.
More precisely, if you are curious
- Well-known relational databases are PostgreSQL, MySQL, Oracle and SQL Server. They run as separate servers. Here we use SQLite: a small database that lives in a file or entirely in the program's memory. In .NET it comes with Microsoft's
Microsoft.Data.Sqlitepackage (dotnet add package Microsoft.Data.Sqlite). The addressData Source=:memory:is a database in memory: it lives while the connection is open, and every run starts with an empty one. - Text in SQL goes in single quotes:
'Coffee'. In PostgreSQL, MySQL and SQL Server,DECIMAL(8, 2)is an exact number with 2 digits after the decimal point, which suits money. SQLite doesn't keep exact decimal numbers: there,7.50is an ordinary floating-point number.
See it
ADO.NET: connection, command, result
SqliteConnection, the query is a SqliteCommand, and you read the result with a SqliteDataReader (for SQL Server they're SqlConnection, SqlCommand and SqlDataReader). The Setup method makes the table and adds three products: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, 'Coffee', 7.50), (2, 'Tea', 3.20), (3, 'Chocolate', 2.40)"; cmd.ExecuteNonQuery();}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);}Product { Id = 3, Name = Chocolate, Price = 2.4 }Product { Id = 2, Name = Tea, Price = 3.2 }Product { Id = 1, Name = Coffee, Price = 7.5 }new SqliteConnection(address)makes the connection, andOpen()opens it. The address (the connection string) says which database: here, SQLite in memory, or a file likeData Source=shop.db.conn.CreateCommand()makes a command, andCommandTextis its SQL.ExecuteNonQuery()is for commands that change things (CREATE,INSERT,UPDATE,DELETE), and it returns how many rows changed.ExecuteReader()is forSELECT.reader.Read()moves to the next row and returnsfalsewhen the rows run out.GetInt32,GetStringandGetDoubleget a column by number: from 0, in the order of theSELECT. By name, it'sreader.GetOrdinal("name").- The connection, the command and the reader have to be closed:
using varcloses them (it callsDispose()) when the block ends. - Errors are
SqliteExceptionexceptions. C# has no checked exceptions, so the methods don't writethrows.
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);}Step by step, in words
- A connection to the database in memory. Setup makes the products table and adds 3 rows.
- An empty list that the objects will go into.
- SELECT with ORDER BY price: ExecuteReader returns a reader over the rows, cheapest first.
- reader.Read() moves to row 1. GetInt32, GetString and GetDouble get the columns by number, and new Product makes an object from them: Chocolate.
- reader.Read() moves to row 2. GetInt32, GetString and GetDouble get the columns by number, and new Product makes an object from them: Tea.
- reader.Read() moves to row 3. GetInt32, GetString and GetDouble get the columns by number, and new Product makes an object from them: Coffee.
- reader.Read() returns false: the rows ran out and the loop stops.
- The list holds ordinary C# objects. Printing shows the record with its fields.
More precisely, if you are curious
- With a real database on a server, the address is different and there's a user and a password. You also need the database's driver as a dependency in the project (for MySQL: the
MySqlConnectorpackage, withMySqlConnectioninstead ofSqliteConnection):
var address = "Server=localhost;Port=3306;Database=shop;User ID=shop_app;Password=secret";using var conn = new MySqlConnection(address);conn.Open();Console.WriteLine("Connected to the database!");- The password isn't written in the code. It's read from the settings or from an environment variable (
appsettings.jsonorEnvironment.GetEnvironmentVariable).
- The prices here are
doubleto keep it short. In a real store, money is adecimal: aDECIMALcolumn in PostgreSQL or SQL Server is read withreader.GetDecimal(2). SQLite doesn't keep exact decimal numbers, so there money is kept as whole cents.
Try it
What will it print?
The products are Coffee €7.50, Tea €3.20 and Chocolate €2.40. What will the program print?
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));}WHERE price < 5 keeps Tea and Chocolate. ORDER BY price DESC sorts them by price from the top down (DESC is descending, and ASC is the default): first Tea for €3.20, then Chocolate for €2.40.
Problem
The search that shows everything
The website searches for a product by the name the user types. The query is put together with $"…{name}…":
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("Searching: Tea");Search(conn, "Tea");Console.WriteLine("Searching: x' OR '1'='1");Search(conn, "x' OR '1'='1");Searching: Tea Tea: €3.2Searching: x' OR '1'='1 Coffee: €7.5 Tea: €3.2 Chocolate: €2.4With "Tea" it works. With x' OR '1'='1 the query becomes ... WHERE name = 'x' OR '1'='1'. The condition is always true, and the whole table comes out. The user's input has become part of the SQL. This is SQL injection, one of the most common attacks on websites. With the same trick, someone can log in without a password or delete a table.
The idea
Parameters: $name
$name, in place of the value, and you pass the value with 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)}"); }}Searching: Tea Tea: €3.2Searching: x' OR '1'='1Now x' OR '1'='1 is just text that is searched for as a name. There's no such product, and nothing comes out. Whatever the user types, it can't become SQL.
$name, @name and :name. In SQL Server and in most .NET examples, you'll see @name.Try it
Coffee with an apostrophe
Coffee 'Arabica' the program stops with SqliteException: SQLite Error 1: 'near "Arabica": syntax error'. Which line has the bug?Pick the line with the bug.
'Arabica' closes the text too early, and the rest is meaningless SQL (near "Arabica" points at the spot). An attacker would put their own query there. The fix is to use parameters: "UPDATE products SET name = $name WHERE id = $id", then cmd.Parameters.AddWithValue("$name", newName) and cmd.Parameters.AddWithValue("$id", id). Then the name is saved exactly as it is, with the apostrophes.The idea
Transaction: all or nothing
Ana transfers €150 to Boris. That's two queries: add to Boris, subtract from Ana. The table has the condition CHECK (balance >= 0), so the second query fails: Ana has only €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, 'Ana', 100), (2, 'Boris', 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("The transfer of €150 was refused.");}PrintBalances(conn);The transfer of €150 was refused. Ana: €100 Boris: €200static 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; }}The transfer of €150 was refused. Ana: €100 Boris: €50conn.BeginTransaction()starts a transaction: the changes aren't final yet. The commands you make after that withCreateCommand()are in it.tx.Commit()saves them all at once.tx.Rollback()undoes them all if something fails.throw;sends the same exception on up, so the caller finds out too.using var txis a safety net: if the transaction reaches the end of the block withoutCommit(),Dispose()undoes it.
More precisely, if you are curious
- The exception for the failed query is a
SqliteExceptionwith the messageSQLite Error 19: 'CHECK constraint failed: no_debt'.19 is SQLite's code for a broken rule (e.SqliteErrorCode).no_debtis the name of the condition: in the table it'sbalance INT CONSTRAINT no_debt CHECK (balance >= 0).
- Databases promise four things about transactions, known as ACID: all or nothing (atomicity), the data stays correct according to the table rules (consistency), transactions running at the same time don't get in each other's way (isolation), and what's saved isn't lost even if the power goes out (durability).
The idea
ORM: objects instead of rows
Microsoft.EntityFrameworkCore.Sqlite package. Another well-known one is Dapper: it's lighter, you write the SQL yourself, and it makes the objects.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")]gives the name of the table. The things in[...]are attributes: notes on the class and its properties that EF Core reads. You'll see them again in the web module.- The
Idproperty is the primary key by convention (the name isIdorProductId), and the database gives it a value. [Required]isNOT NULL, and[MaxLength(50)]gives the length. Properties without an attribute become columns too.- The
DbContextis the connection to the database. EachDbSet<Product>is a table, andOnConfiguringsays which database it is: here, SQLite in memory.
Saving and reading go through the context:
using var db = new ShopContext();db.Database.OpenConnection();db.Database.EnsureCreated();
db.Products.Add(new Product { Name = "Coffee", Price = 7.50 });db.Products.Add(new Product { Name = "Tea", 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}");}Tea: €3.2EnsureCreated() makes the tables. Add adds an object, and SaveChanges() saves all added and changed objects at once, in one transaction. The query is LINQ, as with lists, but EF Core doesn't go through objects in memory: it turns Where(p => p.Price < 5) into SQL, which talks about the class and its Price property, and sends it to the database. ToList() runs the query and returns ready objects.
The database in memory lives while the connection is open, and EF Core otherwise opens and closes it for each query. That's why OpenConnection() keeps it open. With a file (Data Source=shop.db) you don't need this line.
Here on the site, this code runs: EF Core 10 with SQLite is in the checker. The output comes from it.
More precisely, if you are curious
- A LINQ query is code, so the conditions are easy to put together from the user's choices. A method that searches by name:
static List<Product> Named(ShopContext db, string name){ return db.Products.Where(p => p.Name == name).ToList();}- When you need exactly your own SQL,
FromSqlruns it and returns objects. The value in{max}isn't glued in:FromSqlsends it as a parameter, so there's no injection here.FromSqlRawis different: it takes ready text, and if you build it with+or$"…", the value is glued in again.
static List<Product> Cheaper(ShopContext db, double max){ return db.Products.FromSql($"SELECT * FROM products WHERE price < {max}").ToList();}- A transaction that is undone on an error, when one change is several
SaveChangescalls:
using var tx = db.Database.BeginTransaction();try{ db.Products.Add(new Product { Name = "Chocolate", Price = 2.40 }); db.SaveChanges(); // more changes to the objects and SaveChanges tx.Commit();}catch{ tx.Rollback(); throw;}ToQueryString()shows the SQL of the query without running it. That way you can see it's still SQL underneath, andmaxis a parameter:
var max = 5.0;var query = db.Products.Where(p => p.Price < max).OrderBy(p => p.Price);Console.WriteLine(query.ToQueryString());.param set @max 5.0
SELECT "p"."Id", "p"."Name", "p"."Price"FROM "products" AS "p"WHERE "p"."Price" < @maxORDER BY "p"."Price"- In older .NET Framework projects you'll see Entity Framework 6 (
System.Data.Entity). EF Core is the new one, for .NET 5 and later, and the ideas are the same.
Exercise
A store with a database
Microsoft.Data.Sqlite package in the project and the database Data Source=:memory:.1. Catalog. Make a products table and the methods Add, SetPrice, Remove and List, all with parameters ($id, $name, $price) except List. Add Coffee (€7.50), Tea (€3.20) and Chocolate (€2.40), print them, change the price of the coffee to €6.90, delete the chocolate and print again:1. Coffee: €7.52. Tea: €3.23. Chocolate: €2.4---1. Coffee: €6.92. Tea: €3.2Find(conn, text) finds all products whose name contains the text, ignoring upper and lower case. LOWER in SQLite changes only the letters A to Z, so É stays as it is. Give the database a function of your own: conn.CreateFunction("lower_text", (string text) => text.ToLowerInvariant()), then WHERE lower_text(name) LIKE $text with the value "%" + text.ToLowerInvariant() + "%". Try "CO", "éclair" and an injection attempt:Searching for "CO": Cocoa: €4.8 Coffee: €7.5Searching for "éclair": Éclair: €2.4Searching for "' OR '1'='1": no products foundTransfer(conn, from, to, amount) first subtracts from from, then adds to to. If ExecuteNonQuery() returns 0 (no such account), throw InvalidOperationException("No account " + id). Everything is in a transaction from BeginTransaction(). Transfer €30 from 1 to 2, then €40 from 1 to the account 99, which doesn't exist:Transferred €30 from 1 to 2Refused: No account 99 Ana: €70 Boris: €80The second transfer took €40 from Ana before it failed, but Rollback() gave it back.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lists · Lambdas and delegates · LINQ · record, switch and patterns
- Continues in: Injections: when data becomes a command · Monolith, modular monolith, services · Transactional outbox · A transaction across services (saga)
- Tasks on this topic: Real-world programs