Wszystkie wpisy

SQL w praktyce - DDL i DML - CREATE, INSERT, SELECT, UPDATE, DELETE, JOIN

Praktyczny SQL dla programisty C# - podział na DDL/DML/DCL/TCL, CREATE TABLE z kluczami głównymi i obcymi, CRUD (Wielka Czwórka DML), INNER JOIN, klasyczne pułapki UPDATE/DELETE bez WHERE, mapowanie SQL na LINQ w C#. Dwudziesta trzecia część kursu C#.


W poprzednim wpisie Relacyjne bazy danych w C# poznałeś architekturę silnika bazy oraz zasady projektowania tabel. Teraz przechodzimy do języka, którym baza rozmawia z Twoim kodem: SQL (Structured Query Language). Bez względu na to, czy piszesz bezpośrednio przez ADO.NET, czy używasz ORM (Entity Framework Core), pod maską wykonuje się SQL - a jego znajomość odróżnia programistę junior od senior.

W tej części poznasz podział SQL na cztery kategorie (DDL/DML/DCL/TCL - w codziennej pracy używasz głównie dwóch), konkretne polecenia do tworzenia struktury (CREATE, ALTER, DROP) oraz Wielką Czwórkę operacji na danych (INSERT, SELECT, UPDATE, DELETE). Plus klasyczne pułapki produkcyjne i mapowanie SQL na LINQ w C#.

1. Cztery kategorie SQL #

Nie każde polecenie SQL to SELECT. Standard dzieli język na cztery kategorie o różnym przeznaczeniu:

KategoriaRozszyfrowaniePoleceniaCzęstość użycia
DDLData Definition LanguageCREATE, ALTER, DROP, TRUNCATERzadko (setup schematu, migracje)
DMLData Manipulation LanguageINSERT, SELECT, UPDATE, DELETECodziennie (aplikacja biznesowa)
DCLData Control LanguageGRANT, REVOKE, DENYRzadko (uprawnienia - zwykle DBA/DevOps)
TCLTransaction Control LanguageBEGIN, COMMIT, ROLLBACK, SAVEPOINTCzęsto (transakcje dla spójności)

W codziennej pracy programisty 80% czasu spędzasz na DML. DDL to najczęściej migracje (dodanie kolumny, nowa tabela) - zwykle raz na tydzień/miesiąc. TCL implicite w każdej operacji zapisu (EF Core zawija SaveChanges w transakcję automatycznie).

2. DDL - definiowanie struktury #

Trzy najważniejsze polecenia DDL:

  • CREATE - tworzy nowy obiekt (tabelę, indeks, widok, procedurę)
  • ALTER - modyfikuje istniejący (np. dodaje kolumnę)
  • DROP - bezpowrotnie usuwa obiekt wraz z danymi

CREATE TABLE - pełny przykład #

Budujemy strukturę z wpisu wczorajszego - użytkownicy i zamówienia:

-- Tabela Users z Primary Key i podstawowymi kolumnami
CREATE TABLE Users (
    Id INT IDENTITY(1,1) PRIMARY KEY,          -- auto-inkrementacja od 1
    Name NVARCHAR(50) NOT NULL,                 -- Unicode, wymagane
    Email VARCHAR(100) UNIQUE NOT NULL,         -- unikalny w skali tabeli
    CreatedAt DATETIME2 DEFAULT SYSUTCDATETIME()  -- domyślnie aktualny UTC
);

-- Tabela Orders z Foreign Key do Users
CREATE TABLE Orders (
    Id INT IDENTITY(1,1) PRIMARY KEY,
    UserId INT NOT NULL,
    Product NVARCHAR(100) NOT NULL,
    Price DECIMAL(18, 2) NOT NULL,

    -- Klucz obcy z kaskadowym usuwaniem
    CONSTRAINT FK_Orders_Users FOREIGN KEY (UserId)
        REFERENCES Users(Id)
        ON DELETE CASCADE
);

Rozbicie ważnych elementów:

  • IDENTITY(1,1) - automatyczna inkrementacja (pierwszy=1, każdy kolejny +1)
  • NOT NULL - kolumna musi mieć wartość (bez tego domyślnie NULL dozwolone)
  • UNIQUE - wartość unikalna w tabeli (dwaj użytkownicy nie mogą mieć tego samego emaila)
  • DEFAULT - wartość, gdy INSERT jej nie poda
  • CONSTRAINT FK_Orders_Users - nazwany klucz obcy (nazwa pomaga przy debugowaniu)
  • ON DELETE CASCADE - usunięcie usera automatycznie usuwa jego orders

ALTER TABLE - modyfikacje #

-- Dodaj kolumnę
ALTER TABLE Users ADD DateOfBirth DATE NULL;

-- Zmień typ kolumny
ALTER TABLE Users ALTER COLUMN Name NVARCHAR(100) NOT NULL;

-- Dodaj indeks
CREATE INDEX IX_Users_Email ON Users(Email);

-- Usuń kolumnę
ALTER TABLE Users DROP COLUMN DateOfBirth;

W produkcji ALTER TABLE na dużej tabeli może blokować ją na godziny - miej to na uwadze przy migracjach.

DROP - ostateczne usunięcie #

DROP TABLE Orders;   -- usuwa tabelę razem z danymi

DROP jest ostateczne. Bez backupu - nie ma powrotu. Reguła bezpieczeństwa: nigdy nie łącz się z produkcją jako użytkownik z prawem DROP. Użyj konta z tylko DML (SELECT/INSERT/UPDATE/DELETE) - DROP zostaw dla dedykowanego konta migracji.

3. DML - Wielka Czwórka operacji CRUD #

CRUD w REST API to dokładnie DML w SQL:

RESTSQLHTTP
CreateINSERTPOST
ReadSELECTGET
UpdateUPDATEPUT/PATCH
DeleteDELETEDELETE

INSERT - dodawanie wierszy #

-- Podstawowa forma z jawną listą kolumn
INSERT INTO Users (Name, Email)
VALUES (N'Jan Kowalski', '[email protected]');

-- Wstawianie wielu wierszy naraz (efektywne)
INSERT INTO Users (Name, Email) VALUES
    (N'Anna Nowak', '[email protected]'),
    (N'Piotr Wiśniewski', '[email protected]'),
    (N'Ola Kowalczyk', '[email protected]');

-- INSERT z pobraniem Id (SQL Server)
INSERT INTO Users (Name, Email)
OUTPUT INSERTED.Id
VALUES (N'Nowy User', '[email protected]');

Kluczowe niuanse:

  • N'text' - Unicode literal; bez N polskie znaki mogą się zepsuć w NVARCHAR
  • IDENTITY kolumny pomijamy - baza sama wypełni
  • DEFAULT kolumny (jak CreatedAt) też można pominąć
  • OUTPUT INSERTED.Id - zwraca wygenerowany PK (przydatne w aplikacji)

SELECT - odczyt (najbardziej rozbudowane polecenie SQL) #

-- Wszystkie kolumny, wszystkie wiersze - unikaj w produkcji
SELECT * FROM Users;

-- Konkretne kolumny (lepsze)
SELECT Id, Name, Email FROM Users;

-- Filtrowanie WHERE
SELECT Id, Name FROM Users WHERE Email = '[email protected]';

-- Wiele warunków
SELECT * FROM Orders
WHERE Price > 100
  AND UserId = 5;

-- Sortowanie ORDER BY
SELECT * FROM Orders
ORDER BY Price DESC, Product ASC;

-- Paginacja OFFSET/FETCH (SQL Server 2012+)
SELECT * FROM Users
ORDER BY Id
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;   -- strona 3, po 10 rekordów

-- Agregaty
SELECT COUNT(*) FROM Orders WHERE UserId = 1;
SELECT AVG(Price) FROM Orders;
SELECT MIN(Price), MAX(Price) FROM Orders;

-- Grupowanie GROUP BY
SELECT UserId, COUNT(*) AS OrderCount, SUM(Price) AS TotalSpent
FROM Orders
GROUP BY UserId;

-- HAVING - filtr na grupach
SELECT UserId, COUNT(*) AS OrderCount
FROM Orders
GROUP BY UserId
HAVING COUNT(*) > 5;   -- tylko użytkownicy z więcej niż 5 zamówieniami

Uwaga: unikaj SELECT * w produkcji. Lepiej SELECT Id, Name, Email:

  • Mniej danych przesyłanych z bazy do aplikacji
  • Jasny kontrakt - dodanie kolumny Password w bazie nie wysadza aplikacji
  • Lepsza wydajność - baza może użyć covering index (indeks zawierający potrzebne kolumny)

SELECT z INNER JOIN #

Łączenie dwóch tabel przez wspólny klucz:

-- Podstawowy INNER JOIN
SELECT u.Name, o.Product, o.Price
FROM Users u
INNER JOIN Orders o ON u.Id = o.UserId;

-- LEFT JOIN - wszyscy użytkownicy, nawet bez zamówień
SELECT u.Name, o.Product
FROM Users u
LEFT JOIN Orders o ON u.Id = o.UserId;
-- Dla użytkownika bez zamówień: o.Product będzie NULL

-- Wielotabelowy JOIN
SELECT u.Name, o.Product, c.Name AS Category
FROM Users u
INNER JOIN Orders o ON u.Id = o.UserId
INNER JOIN Categories c ON o.CategoryId = c.Id
WHERE o.Price > 1000;

Aliasy (u, o, c) - skróty dla nazw tabel; nie są wymagane, ale zawsze stosuj dla czytelności.

Różnica INNER kontra LEFT:

  • INNER JOIN - tylko wiersze, gdzie istnieje dopasowanie w obu tabelach
  • LEFT JOIN - wszystkie z lewej + dopasowane z prawej (brak = NULL)
  • RIGHT JOIN - odwrotność LEFT (rzadko używany - zwykle piszemy jako LEFT z odwróconą kolejnością tabel)
  • FULL OUTER JOIN - wszystko z obu tabel (brak = NULL po odpowiedniej stronie)

UPDATE - modyfikacja #

-- Podstawowa forma
UPDATE Users
SET Email = '[email protected]'
WHERE Id = 1;

-- Aktualizacja wielu kolumn
UPDATE Orders
SET Price = 2500, Product = N'Laptop Pro'
WHERE Id = 1;

-- UPDATE z warunkiem złożonym
UPDATE Orders
SET Price = Price * 1.1   -- podwyżka 10%
WHERE Product LIKE N'Laptop%'
  AND CreatedAt > '2024-01-01';

KRYTYCZNIE WAŻNE: UPDATE bez WHERE aktualizuje cały dataset!

UPDATE Users SET Email = '[email protected]';
-- ⚠️ WSZYSCY użytkownicy dostają email '[email protected]'

Zasada bezpieczeństwa produkcyjnego:

  1. Napisz SELECT * FROM Users WHERE ... z tym samym warunkiem
  2. Sprawdź dokładnie ile wierszy zaznaczysz
  3. Dopiero potem zamień SELECT * na UPDATE ... SET ...

DELETE - usuwanie wierszy #

-- Usunięcie konkretnego wiersza
DELETE FROM Orders WHERE Id = 5;

-- Usunięcie wielu wierszy z warunkiem
DELETE FROM Orders
WHERE CreatedAt < '2020-01-01';   -- stare zamówienia sprzed 2020

Analogicznie do UPDATE: DELETE FROM Orders; bez WHERE wyczyszcza całą tabelę.

Różnica DELETE kontra TRUNCATE:

DELETE FROM TableTRUNCATE TABLE Table
Warunki WHERE✅ Można❌ Nie można
Wpisy do Transaction LogKażdy wierszMinimalne
Reset IDENTITYNieTak (do seed value)
Wywalane triggeryTakNie (SQL Server)
PrawaDELETEALTER (DDL!)
Odzysk przez rollbackTak (w transakcji)Zależy od bazy
Wydajność dla całej tabeliWolneBłyskawiczne

Dla wyczyszczenia całej tabeli w środowisku deweloperskim: TRUNCATE TABLE Orders; (setki razy szybsze niż DELETE FROM Orders).

4. Klasyczne pułapki produkcyjne #

Pułapka 1: UPDATE/DELETE bez WHERE #

Już wielokrotnie wspomniana - najsłynniejszy bug juniorów. Zawsze najpierw SELECT z tym samym warunkiem.

Pułapka 2: SELECT * w produkcji #

SELECT * FROM Users WHERE Id = 1;
-- Pobiera Password, ApiKey, PrivateSettings...
-- Kolumny dodane w przyszłości też będą się przesyłać

Explicit lista kolumn: jasny kontrakt między aplikacją a bazą.

Pułapka 3: N'text' kontra 'text' dla polskich znaków #

-- ZŁE
INSERT INTO Users (Name) VALUES ('Łódź');   -- może zostać zapisane jako '?ódź'

-- DOBRE
INSERT INTO Users (Name) VALUES (N'Łódź');  -- Unicode - bezbłędnie

Zawsze N'...' gdy kolumna to NVARCHAR.

Pułapka 4: brak indeksów #

SELECT * FROM Orders WHERE UserId = 42;
-- Bez indeksu na UserId: pełen skan tabeli (mogą być miliony wierszy)
-- Z indeksem: logarytmiczne wyszukanie (mikrosekundy)

Regułą kciuka: indeks dla każdej kolumny użytej w WHERE, JOIN, ORDER BY. Ale uwaga: indeksy spowalniają INSERT/UPDATE/DELETE - bilans.

Pułapka 5: N+1 problem #

Aplikacja pobiera 100 użytkowników, potem dla każdego osobne zapytanie o jego zamówienia = 101 zapytań zamiast 1. Rozwiązanie: JOIN lub Include w EF Core (omówione w wpisie o EF Core).

5. SQL kontra LINQ w C# #

Jeśli używasz Entity Framework Core, LINQ zamienia się na SQL automatycznie:

// C# LINQ
var expensiveOrders = context.Orders
    .Where(o => o.Price > 1000)
    .OrderByDescending(o => o.Price)
    .Take(10)
    .ToList();

// Wygenerowany SQL:
// SELECT TOP 10 *
// FROM Orders
// WHERE Price > 1000
// ORDER BY Price DESC

Mapowanie 1:1:

LINQSQL
.Where(...)WHERE
.OrderBy(...)ORDER BY ASC
.OrderByDescending(...)ORDER BY DESC
.Take(N)TOP N (SQL Server) lub LIMIT N (PostgreSQL)
.Skip(N)OFFSET N ROWS
.GroupBy(...)GROUP BY
.Join(...)INNER JOIN
.Select(...)SELECT (kolumny)
.Count()COUNT(*)
.Sum(x => x.Price)SUM(Price)

Znając SQL - znasz LINQ. Znając LINQ - łatwiej Ci będzie SQL. To dwa dialekty tej samej idei.

ADO.NET - klasyczny sposób #

Alternatywa dla EF Core - bezpośredni SQL przez ADO.NET:

using System.Data.SqlClient;

using var conn = new SqlConnection(connectionString);
conn.Open();

using var cmd = new SqlCommand(
    "SELECT Id, Name, Email FROM Users WHERE Id = @id",
    conn);
cmd.Parameters.AddWithValue("@id", 42);

using var reader = cmd.ExecuteReader();
while (reader.Read())
{
    int id = reader.GetInt32(0);
    string name = reader.GetString(1);
    string email = reader.GetString(2);
    Console.WriteLine($"{id}: {name} ({email})");
}

Kluczowe: zawsze używaj parametrów (@id), nigdy nie sklejaj SQL ze stringów:

// ❌ SQL Injection!
string sql = "SELECT * FROM Users WHERE Name = '" + userInput + "'";

// ✅ Bezpieczne - parametr
string sql = "SELECT * FROM Users WHERE Name = @name";
cmd.Parameters.AddWithValue("@name", userInput);

Więcej o SQL Injection - w kolejnych wpisach o bezpieczeństwie.

Podsumowanie tematu #

W tej części kursu poznałeś:

  • Cztery kategorie SQL - DDL (struktura), DML (dane), DCL (uprawnienia), TCL (transakcje); jako programista używasz głównie DDL i DML
  • DDL - CREATE TABLE z kluczami głównymi (IDENTITY), obcymi (FOREIGN KEY REFERENCES), ograniczeniami (NOT NULL, UNIQUE, DEFAULT); ALTER TABLE dla modyfikacji; DROP jako operacja ostateczna
  • Wielka Czwórka DML (CRUD) - INSERT z listą kolumn i wartości, SELECT z WHERE/ORDER BY/GROUP BY/HAVING, UPDATE z krytyczną klauzulą WHERE, DELETE jako operacja usunięcia
  • JOINy - INNER JOIN (tylko dopasowane), LEFT JOIN (wszystko z lewej + NULL dla braku), rzadko RIGHT/FULL OUTER
  • Klasyczne pułapki - UPDATE/DELETE bez WHERE (najsłynniejszy bug juniora), SELECT * w produkcji, N'...' dla polskich znaków, brak indeksów, N+1 problem
  • DELETE kontra TRUNCATE - DELETE z WHERE i log-per-wiersz, TRUNCATE reset całej tabeli, wielokrotnie szybszy dla wyczyszczenia
  • SQL kontra LINQ - mapowanie 1:1 (Where/OrderBy/GroupBy/Join); EF Core zamienia LINQ na SQL automatycznie; ADO.NET dla bezpośrednich zapytań
  • Bezpieczeństwo - zawsze parametry, nigdy stringi łączone z inputem użytkownika (SQL Injection)

W następnym wpisie Więzy integralności w SQL - Primary Key, Foreign Key, CASCADE, UNIQUE, CHECK zejdziemy głębiej w mechanizmy obronne bazy: strategie kaskadowego zachowania ON DELETE, wybór INT IDENTITY kontra GUID jako PK, oraz walidacja logiczna przez CHECK. Później kolejno: indeksy i plan zapytania, transakcje oraz zaawansowane JOINy.

Quiz i zadanie poniżej. Zadanie pokazuje kanoniczny odpowiednik SQL w C#: List jako tabele, foreach jako SELECT, LINQ join jako INNER JOIN, First(...) + modification jako UPDATE WHERE. Znając te paralele, przechodzisz płynnie między dialektami.

Podobne wpisy

🔗 Linkują tu