У попередній статті ми навчилися встановлювати з'єднання з SQL Server — по суті, «підняли телефонну трубку». Але сам по собі відкритий канал зв'язку нічого не дає. Щоб отримати дані або змінити їх, потрібно надіслати команду — SQL-запит.
Уявіть ресторан. SqlConnection — це те, що ви сіли за столик і вам дали меню. Але щоб отримати їжу, потрібно зробити замовлення. SqlCommand — це саме замовлення: ви формулюєте, що хочете («SELECT всі десерти WHERE ціна < 100»), передаєте офіціанту (SQL Server), і він приносить результат.
Клас DbCommand (і його конкретна реалізація SqlCommand) — це центральний компонент ADO.NET для виконання будь-яких SQL-операцій: від простих SELECT-запитів до складних збережених процедур. У цій статті ми детально розглянемо, як створювати, налаштовувати та виконувати команди, а також розберемо три основних методи виконання: ExecuteReader(), ExecuteNonQuery() та ExecuteScalar().
SqlCommand — це конкретна реалізація абстрактного класу DbCommand для MS SQL Server. Цей клас інкапсулює SQL-запит (або ім'я збереженої процедури) та параметри його виконання.
Є кілька способів створити SqlCommand:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Спосіб 1: Конструктор без параметрів
SqlCommand cmd1 = new SqlCommand();
cmd1.CommandText = "SELECT * FROM Products";
cmd1.Connection = connection;
// Спосіб 2: Конструктор з SQL-запитом та з'єднанням (найпоширеніший)
SqlCommand cmd2 = new SqlCommand("SELECT * FROM Products", connection);
// Спосіб 3: Через метод CreateCommand() з'єднання
SqlCommand cmd3 = connection.CreateCommand();
cmd3.CommandText = "SELECT * FROM Products";
// Спосіб 4: З транзакцією (розглянемо у статті про транзакції)
// SqlCommand cmd4 = new SqlCommand("...", connection, transaction);
Який спосіб обрати? У 90% випадків — Спосіб 2: він найкоротший і одразу прив'язує команду до з'єднання. Спосіб 3 (CreateCommand()) корисний, коли ви працюєте з абстрактним DbConnection і не знаєте конкретний тип провайдера.
CommandText. Можливі значення:Text — звичайний SQL-запит (за замовчуванням)StoredProcedure — ім'я збереженої процедуриTableDirect — ім'я таблиці (рідко використовується, не підтримується SqlClient)SqlException. Значення 0 означає нескінченне очікування.DataRow при використанні DataAdapter. Використовується рідко при прямому ADO.NET.Властивість CommandType визначає, як SQL Server «розуміє» текст у CommandText:
// CommandType.Text — за замовчуванням
// CommandText містить SQL-запит у вигляді рядка
using SqlCommand command = new SqlCommand(
"SELECT Id, Name, Price FROM Products WHERE Price > 1000",
connection);
// command.CommandType = CommandType.Text; // не потрібно — це значення за замовчуванням
Це найпоширеніший режим. SQL-запит надсилається на сервер «як є», SQL Server розбирає його (parsing), будує план виконання та повертає результати.
// CommandType.StoredProcedure
// CommandText містить ЛИШЕ ім'я процедури (без EXEC, без дужок)
using SqlCommand command = new SqlCommand("GetProductsByCategory", connection);
command.CommandType = CommandType.StoredProcedure;
// Параметри процедури додаються через Parameters
command.Parameters.AddWithValue("@CategoryId", 5);
При CommandType.StoredProcedure ADO.NET автоматично формує правильний виклик процедури через протокол TDS RPC (Remote Procedure Call). Це ефективніше, ніж писати EXEC GetProductsByCategory @CategoryId = 5 у CommandText з CommandType.Text, оскільки RPC-виклик дозволяє SQL Server пропустити етап розбору SQL-тексту.
// CommandType.TableDirect
// Повертає ВСІ рядки з таблиці (еквівалент SELECT * FROM ...)
// ⚠️ НЕ підтримується SqlClient для SQL Server!
// Працює лише з деякими OLE DB провайдерами
using SqlCommand command = new SqlCommand("Products", connection);
command.CommandType = CommandType.TableDirect; // SqlException!
TableDirect — це історичний артефакт з ери OLE DB. У Microsoft.Data.SqlClient він не підтримується і кине виняток. Ви будете зустрічати його лише в документації — ніколи не використовуйте.
SqlCommand має три основних методи виконання, кожен призначений для конкретного типу SQL-операції. Вибір правильного методу — це ключова навичка при роботі з ADO.NET.
ExecuteReader()
SqlDataReader — потоковий курсор для читання рядків.
Приклад: SELECT * FROM Products WHERE Price > 100ExecuteNonQuery()
int — кількість рядків, змінених запитом.
Приклад: DELETE FROM Products WHERE Id = 5ExecuteScalar()
object? — значення першої клітинки результату.
Приклад: SELECT COUNT(*) FROM ProductsДавайте розглянемо кожен метод детально з повними прикладами.
ExecuteNonQuery() використовується для SQL-команд, які змінюють дані або структуру бази: INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE тощо. Метод повертає int — кількість рядків, які були змінені (affected rows).
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Створюємо INSERT-команду
string insertSql = @"
INSERT INTO Products (Name, Price, Quantity)
VALUES (N'Монітор 32""', 18999.99, 10)";
using SqlCommand command = new SqlCommand(insertSql, connection);
// Виконуємо та перевіряємо результат
int rowsAffected = command.ExecuteNonQuery();
Console.WriteLine($"Вставлено рядків: {rowsAffected}"); // Вивід: 1
Розбір коду:
INSERT INTO додає новий рядок у таблицю Products. Зверніть увагу на N'...' — це літерал Unicode-рядка в T-SQL, що важливо для коректного збереження українських символів.SqlCommand з SQL-текстом та відкритим з'єднанням.ExecuteNonQuery() надсилає запит на SQL Server і повертає 1 — тому що було вставлено один рядок.ExecuteNonQuery().using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Підвищуємо ціну на 10% для всіх товарів з ціною менше 1000
string updateSql = "UPDATE Products SET Price = Price * 1.10 WHERE Price < 1000";
using SqlCommand command = new SqlCommand(updateSql, connection);
int rowsAffected = command.ExecuteNonQuery();
Console.WriteLine($"Оновлено рядків: {rowsAffected}");
if (rowsAffected == 0)
{
Console.WriteLine("⚠️ Жоден рядок не відповідає умові WHERE.");
}
else
{
Console.WriteLine($"✅ Ціну підвищено для {rowsAffected} товарів.");
}
Розбір коду:
UPDATE ... SET ... WHERE змінює ціну для рядків, що відповідають умові.ExecuteNonQuery() повертає кількість змінених рядків. Якщо жоден рядок не відповідає WHERE, повернеться 0 — це не помилка, а валідний результат.rowsAffected — гарна практика, що дозволяє зрозуміти, чи дійсно операція вплинула на дані.using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Видаляємо товари з кількістю 0
string deleteSql = "DELETE FROM Products WHERE Quantity = 0";
using SqlCommand command = new SqlCommand(deleteSql, connection);
int rowsAffected = command.ExecuteNonQuery();
Console.WriteLine($"Видалено рядків: {rowsAffected}");
ExecuteNonQuery() також використовується для DDL (Data Definition Language) — операцій зі структурою бази:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Створення таблиці
string createTableSql = @"
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Logs')
BEGIN
CREATE TABLE Logs (
Id INT IDENTITY(1,1) PRIMARY KEY,
Message NVARCHAR(500) NOT NULL,
Level NVARCHAR(20) NOT NULL DEFAULT 'INFO',
Created DATETIME2 NOT NULL DEFAULT GETDATE()
);
END";
using SqlCommand command = new SqlCommand(createTableSql, connection);
int result = command.ExecuteNonQuery();
Console.WriteLine($"Результат DDL: {result}");
// Для DDL ExecuteNonQuery() повертає -1 (немає "affected rows")
Розбір коду:
IF NOT EXISTS), і створює її лише за потреби. Це ідемпотентний (idempotent) підхід — безпечний для повторного виконання.ExecuteNonQuery() повертає -1, оскільки DDL не змінює рядки даних у традиційному розумінні.ExecuteScalar() — це спеціалізований метод для запитів, які повертають одне значення — перший стовпець першого рядка результату. Все інше ігнорується. Метод повертає object?, який потрібно привести до потрібного типу.
Коли використовувати? Для агрегатних функцій (COUNT, SUM, AVG, MAX, MIN), отримання SCOPE_IDENTITY() після INSERT, перевірки існування або будь-якого запиту, який за логікою повертає одне значення.
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Кількість товарів
using SqlCommand countCmd = new SqlCommand("SELECT COUNT(*) FROM Products", connection);
object? countResult = countCmd.ExecuteScalar();
int totalProducts = Convert.ToInt32(countResult);
Console.WriteLine($"Кількість товарів: {totalProducts}");
// Середня ціна
using SqlCommand avgCmd = new SqlCommand("SELECT AVG(Price) FROM Products", connection);
decimal avgPrice = Convert.ToDecimal(avgCmd.ExecuteScalar());
Console.WriteLine($"Середня ціна: {avgPrice:C}");
// Максимальна ціна
using SqlCommand maxCmd = new SqlCommand("SELECT MAX(Price) FROM Products", connection);
decimal maxPrice = Convert.ToDecimal(maxCmd.ExecuteScalar());
Console.WriteLine($"Найдорожчий товар: {maxPrice:C}");
// Загальна вартість складу
using SqlCommand totalCmd = new SqlCommand(
"SELECT SUM(Price * Quantity) FROM Products", connection);
decimal totalValue = Convert.ToDecimal(totalCmd.ExecuteScalar());
Console.WriteLine($"Загальна вартість: {totalValue:C}");
Розбір коду:
ExecuteScalar() повертає object? — це може бути int, decimal, string, DateTime або DBNull.Value (якщо результат SQL — NULL).Convert.ToInt32() замість прямого (int) cast, тому що SQL Server може повернути long для COUNT(*) на великих таблицях, і Convert коректно обробляє це.decimal, а не double — decimal забезпечує точність до 28-29 знаків, що критично для фінансових операцій.Якщо результат SQL-запиту — це NULL (наприклад, SELECT AVG(Price) FROM Products WHERE 1=0 — порожня таблиця), ExecuteScalar() поверне DBNull.Value, а не C# null:
using SqlCommand cmd = new SqlCommand(
"SELECT AVG(Price) FROM Products WHERE 1 = 0", // Порожній результат
connection);
object? result = cmd.ExecuteScalar();
// ❌ Неправильно — кине InvalidCastException!
// decimal price = (decimal)result;
// ✅ Правильно — перевіряємо на DBNull
if (result != null && result != DBNull.Value)
{
decimal price = Convert.ToDecimal(result);
Console.WriteLine($"Середня ціна: {price:C}");
}
else
{
Console.WriteLine("Немає даних для обчислення.");
}
// ✅ Або компактніше через is-pattern
decimal? nullablePrice = result is DBNull or null ? null : Convert.ToDecimal(result);
Console.WriteLine($"Ціна: {nullablePrice?.ToString("C") ?? "N/A"}");
Розбір коду:
(decimal)result впаде з InvalidCastException, якщо result — це DBNull.Value.null (якщо запит не повернув жодного рядка) та DBNull.Value (якщо значення стовпця — SQL NULL).ExecuteScalar() повертає null, якщо результат порожній (жодного рядка), і DBNull.Value, якщо значення першого стовпця — SQL NULL. Це тонка, але важлива різниця!Дуже поширений сценарій: вставити рядок і одразу отримати його автоматично згенерований Id:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// INSERT + SELECT SCOPE_IDENTITY() — в одному batch
string insertSql = @"
INSERT INTO Products (Name, Price, Quantity)
VALUES (N'Вебкамера', 1599.00, 25);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
using SqlCommand command = new SqlCommand(insertSql, connection);
object? result = command.ExecuteScalar();
int newId = Convert.ToInt32(result);
Console.WriteLine($"Новий товар створено з Id = {newId}");
Розбір коду:
;). Перший — INSERT, другий — SELECT SCOPE_IDENTITY(), який повертає останній згенерований IDENTITY-код у поточному scope.CAST(SCOPE_IDENTITY() AS INT) — SCOPE_IDENTITY() повертає decimal(38,0), тому краще одразу привести до потрібного типу.ExecuteScalar() виконує весь batch, але повертає результат першого SELECT — тобто наш новий Id.SCOPE_IDENTITY(), а не @@IDENTITY?@@IDENTITY повертає останній IDENTITY у будь-якому scope, включно з тригерами. Якщо на таблиці Products є тригер, який вставляє рядок в іншу таблицю з IDENTITY, @@IDENTITY поверне ID з іншої таблиці! SCOPE_IDENTITY() повертає IDENTITY лише в поточному scope — це завжди правильний вибір.ExecuteReader() — це найпотужніший метод, який використовується для SELECT-запитів, що повертають набір рядків. Метод повертає SqlDataReader — об'єкт для потокового читання результатів. Ми детально розглянемо DataReader у наступній статті, а тут познайомимося з базовим використанням.
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
string sql = "SELECT Id, Name, Price, Quantity FROM Products ORDER BY Price DESC";
using SqlCommand command = new SqlCommand(sql, connection);
using SqlDataReader reader = command.ExecuteReader();
// Заголовок таблиці
Console.WriteLine($"{"ID",-5} {"Назва",-25} {"Ціна",12} {"К-ть",8} {"Вартість",14}");
Console.WriteLine(new string('═', 66));
// Читаємо рядки по одному
int rowCount = 0;
decimal grandTotal = 0;
while (reader.Read())
{
int id = reader.GetInt32(0); // Стовпець "Id"
string name = reader.GetString(1); // Стовпець "Name"
decimal price = reader.GetDecimal(2); // Стовпець "Price"
int quantity = reader.GetInt32(3); // Стовпець "Quantity"
decimal lineTotal = price * quantity;
grandTotal += lineTotal;
rowCount++;
Console.WriteLine($"{id,-5} {name,-25} {price,12:C} {quantity,8} {lineTotal,14:C}");
}
Console.WriteLine(new string('═', 66));
Console.WriteLine($"{"Разом:",-44} {rowCount,8} {grandTotal,14:C}");
Розбір коду:
ExecuteReader() надсилає SELECT-запит і повертає SqlDataReader. На цьому етапі дані ще не завантажені — вони «чекають» на сервері.reader.Read() завантажує наступний рядок з результатів. Повертає true, якщо рядок є, false — якщо рядки закінчилися. Перший виклик Read() завантажує перший рядок (а не другий!).GetInt32(), GetString(), GetDecimal() читають значення стовпків за порядковим номером (0-indexed). Ці методи ефективніші за reader["Name"], але вразливіші до зміни порядку стовпців у SELECT.SqlDataReader також обгорнутий у using, бо він утримує з'єднання та мережеві ресурси.SqlCommand можна використовувати повторно, змінюючи CommandText між виконаннями. Це безпечно і іноді зручно:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
using SqlCommand command = connection.CreateCommand();
// Запит 1: кількість товарів
command.CommandText = "SELECT COUNT(*) FROM Products";
int count = Convert.ToInt32(command.ExecuteScalar());
Console.WriteLine($"Товарів: {count}");
// Запит 2: середня ціна (той самий об'єкт command)
command.CommandText = "SELECT AVG(Price) FROM Products";
decimal avg = Convert.ToDecimal(command.ExecuteScalar());
Console.WriteLine($"Середня ціна: {avg:C}");
// Запит 3: вставка нового товару
command.CommandText = @"
INSERT INTO Products (Name, Price, Quantity)
VALUES (N'USB-хаб', 599.00, 40)";
int inserted = command.ExecuteNonQuery();
Console.WriteLine($"Вставлено: {inserted}");
Однак будьте обережні: якщо ви додавали параметри (command.Parameters), вони залишаться після зміни CommandText. Завжди очищуйте параметри, якщо повторно використовуєте команду з іншим запитом:
command.Parameters.Clear(); // Очистити параметри перед новим запитом
SQL Server підтримує виконання кількох SQL-інструкцій в одній команді (batch). Інструкції розділяються символом ;:
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
// Batch: кілька команд в одному запиті
string batchSql = @"
UPDATE Products SET Quantity = Quantity - 1 WHERE Name = N'Ноутбук';
UPDATE Products SET Quantity = Quantity + 5 WHERE Name = N'Клавіатура';
DELETE FROM Products WHERE Quantity <= 0;";
using SqlCommand command = new SqlCommand(batchSql, connection);
int totalAffected = command.ExecuteNonQuery();
Console.WriteLine($"Загалом змінено рядків: {totalAffected}");
// totalAffected = сума affected rows від УСІХ запитів у batch
Розбір коду:
;.ExecuteNonQuery() для batch повертає суму affected rows від усіх інструкцій. Наприклад, якщо перший UPDATE змінив 1 рядок, другий — 1 рядок, DELETE — 0 рядків, результат буде 2.За замовчуванням SQL Server дає команді 30 секунд на виконання. Якщо запит складніший (наприклад, обробка мільйонів рядків), 30 секунд може бути недостатньо:
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
using SqlCommand command = new SqlCommand(
"SELECT * FROM VeryLargeTable WHERE ComplexCondition = 1",
connection);
// Збільшуємо таймаут для довгих запитів
command.CommandTimeout = 120; // 120 секунд (2 хвилини)
// Або вимикаємо таймаут повністю (обережно!)
// command.CommandTimeout = 0; // Нескінченне очікування
try
{
using SqlDataReader reader = command.ExecuteReader();
// ... обробка результатів ...
}
catch (SqlException ex) when (ex.Number == -2) // Timeout
{
Console.WriteLine("⏱️ Запит перевищив таймаут!");
Console.WriteLine(" Рекомендації:");
Console.WriteLine(" 1. Оптимізуйте SQL-запит (додайте індекси)");
Console.WriteLine(" 2. Зменшіть обсяг даних (WHERE, TOP)");
Console.WriteLine(" 3. Збільшіть CommandTimeout як останній варіант");
}
Розбір коду:
CommandTimeout задається для кожної команди окремо (на відміну від ConnectionTimeout, який задається у Connection String).-2 — це таймаут виконання SQL-запиту. Обробляємо його окремо.CommandTimeout = 0 (нескінченне очікування) без вагомої причини! Якщо запит «зависне» (наприклад, через deadlock), ваш додаток теж «зависне» назавжди. Краще збільшити таймаут до розумного значення (наприклад, 300 секунд) і обробити виняток.Коли SQL-запити стають довгими, їх важко читати як однорядкові рядки C#. Використовуйте verbatim-рядки (@"...") або raw string literals (C# 11+) для читабельного форматування:
// ✅ Добре: verbatim string з форматуванням
string sql = @"
SELECT
p.Id,
p.Name,
p.Price,
c.Name AS CategoryName
FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
WHERE p.Price BETWEEN 500 AND 5000
AND p.Quantity > 0
ORDER BY p.Price DESC";
// ✅ Ще краще: raw string literal (C# 11+)
string sqlRaw = """
SELECT
p.Id,
p.Name,
p.Price,
c.Name AS CategoryName
FROM Products p
INNER JOIN Categories c ON p.CategoryId = c.Id
WHERE p.Price BETWEEN 500 AND 5000
AND p.Quantity > 0
ORDER BY p.Price DESC
""";
// ❌ Погано: конкатенація рядків
string sqlBad = "SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName " +
"FROM Products p " +
"INNER JOIN Categories c ON p.CategoryId = c.Id " +
"WHERE p.Price BETWEEN 500 AND 5000 " +
"AND p.Quantity > 0 " +
"ORDER BY p.Price DESC";
Помилки при виконанні SQL-запитів — це повсякденна реальність. SQL Server повертає помилки через SqlException, яка містить детальну інформацію про причину збою:
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
try
{
// Запит з помилкою: таблиця не існує
using SqlCommand command = new SqlCommand(
"SELECT * FROM NonExistentTable", connection);
using SqlDataReader reader = command.ExecuteReader();
}
catch (SqlException ex)
{
Console.WriteLine($"❌ SQL Error #{ex.Number}: {ex.Message}");
Console.WriteLine($" Severity: {ex.Class}");
Console.WriteLine($" State: {ex.State}");
Console.WriteLine($" Server: {ex.Server}");
Console.WriteLine($" Procedure: {ex.Procedure}");
Console.WriteLine($" Line: {ex.LineNumber}");
// Колекція всіх помилок
Console.WriteLine($"\n Усі помилки ({ex.Errors.Count}):");
foreach (SqlError error in ex.Errors)
{
Console.WriteLine($" [{error.Number}] {error.Message}");
}
}
| Код | Причина | Приклад |
|---|---|---|
| 208 | Таблиця не існує | SELECT * FROM NonExistentTable |
| 207 | Стовпець не існує | SELECT NonExistentColumn FROM Products |
| 547 | Порушення FK | DELETE батьківського рядка, на який є посилання |
| 2627 | Порушення UNIQUE | INSERT дублікату |
| 2601 | Порушення UNIQUE INDEX | Дублікат у унікальному індексі |
| 515 | NULL у NOT NULL стовпець | INSERT без обов'язкового поля |
| 8152 | Рядок занадто довгий | VARCHAR(10) отримує рядок з 20 символів |
| 1205 | Deadlock | Взаємне блокування транзакцій |
| -2 | Таймаут | Запит не завершився за CommandTimeout |
Перед тим як перейти до наступної теми, необхідно звернути увагу на найнебезпечніший anti-pattern в ADO.NET — конкатенацію користувацьких даних у SQL-запит:
// ❌ НІКОЛИ НЕ РОБІТЬ ТАК!
Console.Write("Введіть назву товару: ");
string userInput = Console.ReadLine()!; // Що введе користувач?
// Якщо userInput = "Ноутбук' OR '1'='1" — ОТРИМАЄМО ВСІ рядки!
// Якщо userInput = "'; DROP TABLE Products; --" — ВТРАТИМО ТАБЛИЦЮ!
string dangerousSql = $"SELECT * FROM Products WHERE Name = '{userInput}'";
using SqlCommand command = new SqlCommand(dangerousSql, connection);
Якщо зловмисний користувач введе '; DROP TABLE Products; --, ваш SQL-запит стане:
SELECT * FROM Products WHERE Name = ''; DROP TABLE Products; --'
SQL Server виконає два запити: порожній SELECT та DROP TABLE Products. Ваша таблиця зникне.
Рішення: Завжди використовуйте параметризовані запити, які ми детально розглянемо у статті 9.5. Ось як виглядає безпечний код:
// ✅ ПРАВИЛЬНО: параметризований запит
string safeSql = "SELECT * FROM Products WHERE Name = @Name";
using SqlCommand command = new SqlCommand(safeSql, connection);
command.Parameters.AddWithValue("@Name", userInput);
// Тепер userInput — це просто ЗНАЧЕННЯ, а не частина SQL-коду
У безпечному варіанті userInput передається як параметр і ніколи не стає частиною SQL-коду. SQL Server чітко розрізняє «код» (SQL-запит) та «дані» (значення параметра).
Об'єднаємо все вивчене в одну програму, яка виконує всі чотири CRUD-операції:
using System;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;";
using SqlConnection connection = new SqlConnection(connectionString);
connection.Open();
Console.WriteLine("╔═══════════════════════════════════╗");
Console.WriteLine("║ 📦 CRUD Demo: Products Table ║");
Console.WriteLine("╚═══════════════════════════════════╝\n");
using SqlCommand command = connection.CreateCommand();
// === CREATE (INSERT) ===
Console.WriteLine("📝 CREATE: Додаємо новий товар...");
command.CommandText = @"
INSERT INTO Products (Name, Price, Quantity)
VALUES (N'Зарядний пристрій', 899.99, 60);
SELECT CAST(SCOPE_IDENTITY() AS INT);";
int newId = Convert.ToInt32(command.ExecuteScalar());
Console.WriteLine($" ✅ Створено товар з Id = {newId}");
// === READ (SELECT) ===
Console.WriteLine("\n📖 READ: Читаємо всі товари...");
command.CommandText = "SELECT Id, Name, Price, Quantity FROM Products ORDER BY Id";
using (SqlDataReader reader = command.ExecuteReader())
{
Console.WriteLine($" {"ID",-5} {"Назва",-25} {"Ціна",10} {"К-ть",6}");
Console.WriteLine($" {new string('-', 48)}");
while (reader.Read())
{
Console.WriteLine(
$" {reader.GetInt32(0),-5} " +
$"{reader.GetString(1),-25} " +
$"{reader.GetDecimal(2),10:N2} " +
$"{reader.GetInt32(3),6}");
}
}
// === UPDATE ===
Console.WriteLine($"\n✏️ UPDATE: Змінюємо ціну товару Id={newId}...");
command.CommandText = $"UPDATE Products SET Price = 749.99 WHERE Id = {newId}";
int updated = command.ExecuteNonQuery();
Console.WriteLine($" ✅ Оновлено рядків: {updated}");
// === DELETE ===
Console.WriteLine($"\n🗑️ DELETE: Видаляємо товар Id={newId}...");
command.CommandText = $"DELETE FROM Products WHERE Id = {newId}";
int deleted = command.ExecuteNonQuery();
Console.WriteLine($" ✅ Видалено рядків: {deleted}");
// === Перевірка ===
Console.WriteLine("\n📊 Фінальний стан:");
command.CommandText = "SELECT COUNT(*) FROM Products";
Console.WriteLine($" Товарів у базі: {command.ExecuteScalar()}");
Розбір коду:
connection.CreateCommand() і повторно використовуємо один і той самий command для всіх операцій, змінюючи CommandText.ExecuteScalar() — отримуємо Id нового рядка.ExecuteReader(), обгорнутий у using-блок. Зверніть увагу: ми використовуємо блок using (...) замість декларації using, тому що після закриття reader ми хочемо повторно використовувати command.ExecuteNonQuery().newId підставляється через інтерполяцію рядка ($"...{newId}..."). Це безпечно лише тому, що newId — це int, згенерований нашим кодом, а не введений користувачем. Для будь-яких даних від користувача завжди використовуйте параметризовані запити!Створіть програму, яка підключається до SQL Server і за допомогою ExecuteScalar() виводить:
SELECT COUNT(*) FROM sys.tables)SELECT COUNT(*) FROM sys.procedures)sp_spaceused)Напишіть метод bool TableExists(SqlConnection connection, string tableName), який за допомогою ExecuteScalar() перевіряє, чи існує таблиця з заданою назвою в базі. Використайте запит до sys.tables або INFORMATION_SCHEMA.TABLES.
Створіть базу даних з таблицями Categories та Products (з FK). Напишіть програму, яка:
GROUP BY + ExecuteReader()).Створіть таблицю EventLog (Id, EventType, Message, CreatedAt) і напишіть клас EventLogger:
Log(string eventType, string message) — вставляє запис і повертає ID.GetEventCount(string eventType) — повертає кількість подій заданого типу.GetLatestEvents(int count) — повертає останні N подій (SELECT TOP).ClearOldEvents(int daysOld) — видаляє події старші за N днів.Створіть консольний додаток з меню:
Забезпечте обробку помилок (SqlException) та валідацію введення.
Створіть утилітарний клас SqlQueryExecutor, який інкапсулює типові операції:
T? ExecuteScalar<T>(string sql) — виконує запит і повертає типізований результат.int ExecuteCommand(string sql) — повертає affected rows.List<Dictionary<string, object>> ExecuteQuery(string sql) — повертає список рядків як словники.SqlException та DBNull.Три методи Execute
ExecuteReader() для SELECT, ExecuteNonQuery() для INSERT/UPDATE/DELETE, ExecuteScalar() для одного значення. Вибір методу визначається типом SQL-операції.CommandType
Text для SQL-запитів (за замовчуванням), StoredProcedure для збережених процедур. TableDirect не підтримується SqlClient.CommandTimeout
0 = нескінченне очікування (небезпечно).SQL Injection
SqlDataReader)object?)SqlDataReader — ефективний потоковий курсор для читання результатів SELECT. Дізнаємося про типізовані методи, обробку NULL, маппінг на POCO-об'єкти та Multiple Result Sets.