diff --git a/AdoNetShop/Data/ProductRepository.cs b/AdoNetShop/Data/ProductRepository.cs index a4c27fa..e161f4e 100644 --- a/AdoNetShop/Data/ProductRepository.cs +++ b/AdoNetShop/Data/ProductRepository.cs @@ -156,8 +156,77 @@ public class ProductRepository // Метод Fill открывает подключение (если оно закрыто), выполняет запрос, // заполняет DataTable и закрывает подключение (если оно было закрыто). // Это и есть автономный режим. + // Fill() синхронный, поэтому оборачиваем его в Task.Run, чтобы не блокировать поток. await Task.Run(() => adapter.Fill(table)); return table; } + + // ============ Вызов хранимой процедуры (CommandType.StoredProcedure) ============ + + // Демонстрация получения данных через хранимую процедуру dbo.GetProducts. + // В SQL передаётся не текст запроса, а ИМЯ процедуры. + public async Task> GetProductsByStoredProcedureAsync(int? categoryId) + { + var products = new List(); + + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(); + + // Главное отличие от обычного запроса: CommandType.StoredProcedure + текст = имя процедуры + await using var command = new SqlCommand("dbo.GetProducts", connection) + { + CommandType = CommandType.StoredProcedure + }; + + // Параметры хранимой процедуры передаются точно так же, как параметры запроса. + // SQL Server сам подставит значения вместо @CategoryId внутри процедуры. + command.Parameters.Add("@CategoryId", SqlDbType.Int).Value = (object?)categoryId ?? DBNull.Value; + + await using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + products.Add(new Product + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Description = reader.IsDBNull(2) ? null : reader.GetString(2), + Price = reader.GetDecimal(3), + StockQuantity = reader.GetInt32(4), + CategoryId = reader.GetInt32(5) + }); + } + + return products; + } + + // Вызов процедуры, которая изменяет данные, и получение числа затронутых строк. + // Здесь же обрабатывается ошибка, которую процедура отдаёт через RAISERROR. + public async Task UpdatePriceViaStoredProcedureAsync(int productId, decimal newPrice) + { + await using var connection = _connectionFactory.CreateConnection(); + await connection.OpenAsync(); + + await using var command = new SqlCommand("dbo.UpdateProductPrice", connection) + { + CommandType = CommandType.StoredProcedure + }; + + command.Parameters.Add("@ProductId", SqlDbType.Int).Value = productId; + command.Parameters.Add("@NewPrice", SqlDbType.Decimal).Value = newPrice; + + try + { + int rowsAffected = await command.ExecuteNonQueryAsync(); + return rowsAffected > 0; + } + catch (SqlException ex) + { + // SqlException - это исключение самого SQL Server. + // В нём есть номер ошибки (Number) и текст сообщения от сервера. + Console.WriteLine($"Ошибка SQL Server (код {ex.Number}): {ex.Message}"); + return false; + } + } } diff --git a/AdoNetShop/Program.cs b/AdoNetShop/Program.cs index 2dc8091..7d2b76a 100644 --- a/AdoNetShop/Program.cs +++ b/AdoNetShop/Program.cs @@ -44,6 +44,7 @@ class Program Console.WriteLine("8. Создать заказ (Демонстрация Transaction)"); Console.WriteLine("9. Просмотреть заказы"); Console.WriteLine("10. Демонстрация DataAdapter (Disconnected mode)"); + Console.WriteLine("11. Демонстрация Stored Procedures"); Console.WriteLine("0. Выход"); Console.WriteLine("================================"); Console.Write("Выберите действие: "); @@ -84,6 +85,9 @@ class Program case "10": await DemoDataAdapter(shopService); break; + case "11": + await DemoStoredProcedures(shopService); + break; case "0": exit = true; break; @@ -294,4 +298,49 @@ class Program Console.WriteLine($"{id}\t{name,-20}\t{price:C}"); } } + + // Демонстрация вызова хранимых процедур через ADO.NET + private static async Task DemoStoredProcedures(ShopService service) + { + Console.WriteLine("\n--- Демонстрация Stored Procedures ---"); + + await ShowAllCategories(service); + Console.Write("Введите Id категории (0 = все товары): "); + if (!int.TryParse(Console.ReadLine(), out int categoryId)) + { + Console.WriteLine("Некорректный ввод."); + return; + } + + // Вызов dbo.GetProducts. В C# мы передаём имя процедуры, а не SQL-запрос. + Console.WriteLine($"\nВызываем хранимую процедуру dbo.GetProducts @CategoryId = {(categoryId == 0 ? "NULL" : categoryId.ToString())}..."); + var products = await service.GetProductsByStoredProcedureAsync(categoryId == 0 ? null : categoryId); + + Console.WriteLine($"Процедура вернула товаров: {products.Count}"); + foreach (var p in products) + { + Console.WriteLine($" [{p.Id}] {p.Name} — {p.Price:C} (остаток: {p.StockQuantity})"); + } + + if (products.Count == 0) + { + Console.WriteLine("\nНет данных для изменения цены."); + return; + } + + Console.WriteLine($"\nВызываем dbo.UpdateProductPrice для товара [{products[0].Id}]: {products[0].Name}"); + Console.Write("Новая цена (введите -5, чтобы увидеть ошибку от SQL Server): "); + if (!decimal.TryParse(Console.ReadLine(), out decimal newPrice)) + { + Console.WriteLine("Некорректная цена."); + return; + } + + // Процедура сама проверит цену и вернёт ошибку через RAISERROR. + // Мы её перехватываем как SqlException и показываем пользователю. + bool updated = await service.UpdatePriceViaStoredProcedureAsync(products[0].Id, newPrice); + Console.WriteLine(updated + ? $"Цена товара [{products[0].Id}] успешно изменена на {newPrice:C}" + : "Цена не была изменена (смотрите сообщение об ошибке выше)."); + } } diff --git a/AdoNetShop/Services/ShopService.cs b/AdoNetShop/Services/ShopService.cs index a15a66a..b68da47 100644 --- a/AdoNetShop/Services/ShopService.cs +++ b/AdoNetShop/Services/ShopService.cs @@ -42,6 +42,13 @@ public class ShopService public Task> GetAllCategoriesAsync() => _categoryRepository.GetAllAsync(); public Task> GetAllCustomersAsync() => _customerRepository.GetAllAsync(); + // Прокси-методы для демонстрации хранимых процедур + public Task> GetProductsByStoredProcedureAsync(int? categoryId) + => _productRepository.GetProductsByStoredProcedureAsync(categoryId); + + public Task UpdatePriceViaStoredProcedureAsync(int productId, decimal newPrice) + => _productRepository.UpdatePriceViaStoredProcedureAsync(productId, newPrice); + public async Task CreateOrderAsync(int customerId, List<(int ProductId, int Quantity)> requestedItems) { // 1. Формируем заказ diff --git a/README.md b/README.md index 1ae7c30..cf0f0b5 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,970 @@ -# AdoNetShop: Твоя первая лабораторная по ADO.NET 🎓 +# AdoNetShop — лабораторная работа по технологии ADO.NET -Привет! Это учебный проект интернет-магазина. Его главная цель — показать тебе, как работает технология **ADO.NET** изнутри. Мы будем общаться с базой данных Microsoft SQL Server "вручную", без всяких умных прослоек вроде Entity Framework. Это нужно, чтобы ты понял самые основы. +**Что это за проект.** Это небольшой учебный интернет-магазин, написанный на C# (.NET 8). +Всё общение с базой данных Microsoft SQL Server выполняется **вручную через ADO.NET** +(пакет `Microsoft.Data.SqlClient`). В проекте **нет** Entity Framework, Dapper, NHibernate +и любых других ORM — нам важно увидеть, как приложение общается с БД «на низком уровне». --- -## 📚 Немного скучной (но нужной) теории простыми словами +## Содержание -### Что вообще такое ADO.NET? -Представь, что твоя программа (на C#) и база данных (SQL Server) говорят на разных языках и живут в разных городах. ADO.NET — это набор инструментов (переводчиков, почтальонов, телефонов), которые позволяют им общаться. -Мы не используем ORM (Entity Framework), потому что ORM делает всё за тебя "под капотом". В этой лабе мы лезем под капот сами. - -### Зачем мы это учим? -1. **Чтобы понимать, как всё работает на самом деле.** Любая крутая ORM в итоге всё равно превращает свой код в команды ADO.NET. -2. **Скорость и контроль.** Иногда нужно написать супер-хитрый и быстрый запрос. Ручной SQL через ADO.NET даст тебе максимальную скорость. - -### Главные герои ADO.NET (Что нужно знать для защиты): - -* 🔌 **`SqlConnection` (Подключение):** Это твой телефонный провод до базы данных. Чтобы что-то сказать базе, нужно поднять трубку (`Open()`), а потом обязательно положить её (`Close()`, или использовать конструкцию `using`). -* 📝 **`SqlCommand` (Команда):** Это то, *что* ты говоришь в трубку. Обычно это SQL-запросы (`SELECT`, `INSERT` и т.д.). -* 🛡️ **`SqlParameter` (Параметр):** Это твоя защита от злодеев. **Никогда** не склеивай SQL-строку плюсами (типа `"SELECT * FROM Users WHERE Name = " + name`). Хакерам хватит 5 минут, чтобы сломать базу через SQL-инъекцию. Мы передаем переменные *только* через параметры (`@Name`). -* 📖 **`SqlDataReader` (Чтец данных):** Представь, что база диктует тебе длиннющий список товаров по телефону. `DataReader` слушает и записывает строку за строкой. Он работает очень быстро, но *только вперед* и требует, чтобы телефонная линия (Connection) была открыта. (Это называется **Connected** подход). -* 📦 **`SqlDataAdapter` и `DataTable`:** А это другой подход. `DataAdapter` — это курьер. Он сам едет к базе, забирает *все* данные разом, привозит их тебе и кладет в коробку (`DataTable` в памяти компьютера). После этого он сам вешает трубку. Теперь ты можешь ковыряться в этой коробке сколько угодно, не занимая базу данных. (Это называется **Disconnected** подход). -* 🤝 **Транзакция (`SqlTransaction`):** Сделка по принципу "Всё или ничего". Представь перевод денег: списали у тебя, но не зачислили другу. Плохо! Транзакция гарантирует: если база начала делать заказ (создала заказ -> списала товары со склада -> добавила позиции), и вдруг свет моргнул на списании склада — она отменит всё обратно (`Rollback`). Если всё супер — закрепит (`Commit`). +1. [Что такое ADO.NET и зачем он нужен](#1-что-такое-adonet-и-зачем-он-нужен) +2. [Что такое Provider (провайдер данных)](#2-что-такое-provider-провайдер-данных) +3. [Основные классы ADO.NET, которые используются в проекте](#3-основные-классы-adonet-используемые-в-проекте) + - 3.1 `SqlConnection` — подключение к базе + - 3.2 `SqlCommand` — команда (запрос) + - 3.3 `SqlParameter` — параметры и защита от SQL Injection + - 3.4 `SqlDataReader` — чтение результатов (connected) + - 3.5 `SqlDataAdapter` и `DataTable` — disconnected + - 3.6 `SqlTransaction` — транзакции +4. [Connected vs Disconnected: в чём разница](#4-connected-vs-disconnected-в-чём-разница) +5. [Структура проекта](#5-структура-проекта) +6. [Структура базы данных](#6-структура-базы-данных) +7. [Как установить SQL Server](#7-как-установить-sql-server) +8. [Как создать базу данных и выполнить SQL-скрипты](#8-как-создать-базу-данных-и-выполнить-sql-скрипты) +9. [Как изменить строку подключения (Connection String)](#9-как-изменить-строку-подключения-connection-string) +10. [Как запустить проект](#10-как-запустить-проект) +11. [Что умеет программа (демонстрация преподавателю)](#11-что-умеет-программа-демонстрация-преподавателю) +12. [Обработка ошибок и валидация](#12-обработка-ошибок-и-валидация) +13. [Какие файлы нужно изучить в первую очередь](#13-какие-файлы-нужно-изучить-в-первую-очередь) +14. [Частые проблемы и их решения](#14-частые-проблемы-и-их-решения) --- -## 🚀 Как запустить эту штуку на твоем компе +## 1. Что такое ADO.NET и зачем он нужен -Чтобы проект заработал, тебе нужны две вещи: работающая база данных и запущенный код. +**ADO.NET** (ActiveX Data Objects .NET) — это набор классов, входящих в состав .NET, +который позволяет приложению на C# общаться с источниками данных: реляционными базами +данных, XML-файлами и т.д. -### Шаг 1: Готовим Базу Данных -1. Убедись, что у тебя установлен **Microsoft SQL Server** (подойдет версия Express) и программа для управления им, например, **SQL Server Management Studio (SSMS)**. -2. Открой папку `database` в этом проекте. -3. Открой SSMS и подключись к своему серверу. -4. Выполни скрипты строго по очереди: - * ▶️ Сначала выполни код из файла `01_create_database.sql` (Это создаст пустую БД). - * ▶️ Затем `02_create_tables.sql` (Создаст нужные таблички). - * ▶️ Затем `03_insert_data.sql` (Заполнит их тестовыми товарами и клиентами). +Проще всего представить ADO.NET так: -### Шаг 2: Настраиваем проект -1. Открой файл `AdoNetShop/appsettings.json`. -2. Найди там строку `ConnectionStrings -> DefaultConnection`. -3. Тебе нужно сказать программе, как зовут твой SQL сервер. - * Если у тебя обычный разработческий SQL, оставь `Server=localhost`. - * Если ты ставил SQL Server Express, скорее всего тебе нужно поменять это на `Server=.\\SQLEXPRESS`. +``` +┌──────────────────────┐ ADO.NET ┌──────────────────────┐ +│ Приложение на C# │ ◄──────────────────► │ SQL Server │ +│ (ваш код) │ команды и результаты │ (данные) │ +└──────────────────────┘ └──────────────────────┘ +``` -### Шаг 3: Запускаем! -1. Открой терминал (или командную строку) в папке `AdoNetShop` (там, где лежит файл `.csproj`). -2. Напиши команду: `dotnet run` -3. Должно появиться красивое консольное меню магазина. +Приложение не «знает» SQL Server напрямую. Между ними стоит слой ADO.NET, который +переводит вызовы C# (открыть соединение, выполнить запрос, прочитать результат) +в сетевые сообщения протокола SQL Server. + +**Зачем это нужно:** + +1. **Прямой контроль над SQL.** Вы сами пишете запрос — значит, вы можете использовать + всё, что умеет конкретная СУБД: индексы, хинты, `JOIN`, оконные функции, `OUTPUT`, + временные таблицы. Никакой ORM не даст вам такой свободы без «хаков». +2. **Производительность.** ORM делает много «умной» работы (отслеживание изменений, + ленивая загрузка, кэширование), которая часто не нужна. Прямой ADO.NET — это + минимальные накладные расходы. Для учебных и для production-задач вродя отчётов + ADO.NET часто быстрее. +3. **Понимание того, как всё работает на самом деле.** Даже если потом вы будете писать + на Entity Framework, понимание ADO.NET помогает понять, что ORM делает под капотом. +4. **Не зависимость от лишних зависимостей.** ADO.NET — часть платформы .NET, + нужен только один NuGet-пакет провайдера. --- -## 👨‍🏫 Что показывать преподавателю на защите? +## 2. Что такое Provider (провайдер данных) -Препод точно спросит, где и как ты использовал ADO.NET. Вот твоя шпаргалка: +ADO.NET — это набор **интерфейсов и абстракций**, но он не умеет сам ходить в базу. +Для каждой СУБД существует конкретная реализация — **провайдер данных** (Data Provider). -1. **Как ты подключаешься к базе?** - * Покажи файл `Data/DbConnectionFactory.cs`. Расскажи, что строку подключения ты берешь из конфига (это хорошая практика!), а не хардкодишь в коде. -2. **Как работает CRUD (Создание, Чтение, Обновление, Удаление)?** - * Открой `Data/ProductRepository.cs`. - * Покажи метод `GetByIdAsync`. Обрати внимание на `SqlCommand` и то, как ты добавляешь параметры (`command.Parameters.Add`). Расскажи про `SqlDataReader`. - * Покажи метод `CreateAsync`. Обрати внимание на `ExecuteScalarAsync` — объясни, что он нужен, чтобы база вернула тебе ID только что созданного товара. -3. **Где у тебя транзакция?** - * Гордо открывай `Data/OrderRepository.cs` и показывай метод `CreateOrderWithTransactionAsync`. - * Объясни: "Сначала мы открываем транзакцию. Потом пытаемся создать заказ и списать товары. Если хоть одного товара нет на складе, я выбрасываю ошибку, код прыгает в блок `catch`, и делает `RollbackAsync()` — отменяет всё. Если всё гладко — `CommitAsync()`". - * *Для эффекта:* Запусти программу, нажми пункт "8" и попробуй купить 1000 ноутбуков (их столько нет). Покажи, что заказ не создался. -4. **А где Disconnected подход (отсоединенная модель)?** - * Открой пункт меню "10" в запущенной программе. - * Открой метод `GetProductsTableAsync` в `ProductRepository.cs`. - * Покажи класс `SqlDataAdapter` и `DataTable`. Объясни, что курьер (адаптер) сам сбегал в базу, залил всё в таблицу и разорвал связь с БД. +| СУБД | Провайдер (пакет NuGet) | +|---|---| +| Microsoft SQL Server | `Microsoft.Data.SqlClient` | +| PostgreSQL | `Npgsql` | +| MySQL / MariaDB | `MySqlConnector` | +| Oracle | `Oracle.ManagedDataAccess` | +| SQLite | `Microsoft.Data.Sqlite` | -Удачи на защите! Ты справишься! 💪 +В нашем проекте используется **`Microsoft.Data.SqlClient`** — официальный провайдер +для SQL Server. Именно он предоставляет классы `SqlConnection`, `SqlCommand`, +`SqlDataReader`, `SqlDataAdapter`, `SqlTransaction`. + +> **Зачем провайдер вынесен в отдельный пакет, а не встроен в .NET?** +> Разработчики .NET не могут включить в платформу драйверы ко всем существующим СУБД — +> это слишком много кода и лицензионные ограничения. Поэтому каждый провайдер — +> отдельный NuGet-пакет, который подключается одной строкой в `.csproj`: + +```xml + +``` + +--- + +## 3. Основные классы ADO.NET, используемые в проекте + +### 3.1 `SqlConnection` — подключение к базе + +`SqlConnection` — это объект, представляющий **физическое соединение** с SQL Server. + +Создаётся из **строки подключения** (connection string), которая содержит всю +необходимую информацию: адрес сервера, имя базы, способ аутентификации. + +```csharp +// Файл: AdoNetShop/Data/DbConnectionFactory.cs +await using var connection = new SqlConnection(connectionString); + +// Открываем физическое подключение к SQL Server +await connection.OpenAsync(); +``` + +Три важных правила: + +1. **Создать** — `new SqlConnection(connectionString)`. Сам по себе объект ещё не + подключён, это просто «заготовка». +2. **Открыть** — `Open()` / `OpenAsync()`. Только после этого можно выполнять команды. + Строка подключения читается именно в этот момент (ленивое подключение). +3. **Закрыть/освободить** — `Close()` / `Dispose()`. Подключения — ограниченный ресурс + (пул ограничен по числу соединений), поэтому их обязательно нужно закрывать. + +В проекте мы **нигде не вызываем `Close()` вручную**, а всегда используем `await using`: + +```csharp +await using var connection = _connectionFactory.CreateConnection(); +await connection.OpenAsync(); +// ... работа с базой ... +// при выходе из блока соединение автоматически закроется и освободится, +// даже если внутри произошло исключение +``` + +`using` / `await using` — это конструкция языка C#, которая гарантирует вызов +`Dispose()`. Для `SqlConnection`, `SqlCommand`, `SqlDataReader` это обязательно, +потому что они используют неуправляемые ресурсы. + +--- + +### 3.2 `SqlCommand` — команда (запрос) + +`SqlCommand` — это объект, который содержит **что именно** мы хотим сделать с базой: +SQL-запрос, имя хранимой процедуры или текст команды. + +```csharp +await using var command = new SqlCommand( + "SELECT Id, Name, Price FROM Products WHERE CategoryId = @CategoryId", + connection); +``` + +Три способа выполнить команду: + +| Метод | Что возвращает | Когда использовать | +|---|---|---| +| `ExecuteReaderAsync()` | `SqlDataReader` | `SELECT` — когда нужно прочитать много строк | +| `ExecuteScalarAsync()` | одно значение (`object`) | `SELECT COUNT(*)`, `SELECT SCOPE_IDENTITY()` | +| `ExecuteNonQueryAsync()` | число затронутых строк (`int`) | `INSERT`, `UPDATE`, `DELETE` | + +Примеры из проекта (`AdoNetShop/Data/ProductRepository.cs`): + +```csharp +// ExecuteScalarAsync — получить Id только что вставленного товара +// (SCOPE_IDENTITY() возвращает Id, который сгенерировала база для новой строки) +string query = @" + INSERT INTO Products (Name, Description, Price, StockQuantity, CategoryId) + VALUES (@Name, @Description, @Price, @StockQuantity, @CategoryId); + SELECT SCOPE_IDENTITY();"; +var newId = await command.ExecuteScalarAsync(); +return Convert.ToInt32(newId); + +// ExecuteNonQueryAsync — обновить товар, вернётся количество затронутых строк +int rowsAffected = await command.ExecuteNonQueryAsync(); +return rowsAffected > 0; +``` + +--- + +### 3.3 `SqlParameter` — параметры и защита от SQL Injection + +**Это самая важная тема для защиты лабораторной.** + +Правило №1 при работе с любой БД: **никогда не собирайте SQL-запрос склейкой строк.** + +❌ **НЕЛЬЗЯ** так — это SQL Injection: + +```csharp +// ОПАСНО! Не делайте так никогда. +string query = "SELECT * FROM Products WHERE Name = '" + name + "'"; +``` + +Почему это опасно: если пользователь введёт в поле имя товара + +``` +' OR '1'='1 +``` + +то запрос превратится в `SELECT * FROM Products WHERE Name = '' OR '1'='1'` — +вернётся вся таблица. Это и есть **SQL Injection**: злоумышленник может подставить +`'; DROP TABLE Products; --` и удалить таблицу. + +✅ **ПРАВИЛЬНО** так — использовать параметры: + +```csharp +// Безопасно. Значение не «вклеивается» в текст запроса, +// база получает его как отдельный типизированный параметр. +command.Parameters.Add("@Id", SqlDbType.Int).Value = id; +``` + +Что здесь важно: + +* `@Id` — имя параметра в SQL-тексте, оно **не является** значением. +* `SqlDbType.Int` — явно указываем SQL-тип. Это правильнее, чем + `AddWithValue`, потому что `AddWithValue` передаёт .NET-тип, а SQL Server может + неправильно его интерпретировать (например, объект станет `nvarchar`, и запрос + по числовому полю будет работать медленнее или вообще иначе). +* `SqlClient` отправляет значение и тип **отдельно от текста запроса**, поэтому + вставить произвольный SQL через значение параметра невозможно. + +Также обратите внимание, что текст SQL в проекте всегда пишется константой +(через `@"..."` — многострочная строка), а пользовательские данные подставляются +**только** через `Parameters`. Поиск по коду не должен находить конкатенацию +пользовательских данных с SQL. + +--- + +### 3.4 `SqlDataReader` — чтение результатов запроса + +`SqlDataReader` — «читатель» результата `SELECT`. Он работает в режиме +**forward-only** (только вперёд) и **read-only** (только чтение). + +Что это значит на практике: + +* Данные приходят **по одной строке**, а не сразу все. Это экономит память — + можно обработать миллион строк, не загружая их в память целиком. +* Нельзя вернуться на предыдущую строку (нельзя «отмотать назад»). +* Пока `reader` открыт, **нельзя** выполнять другие команды на том же подключении. + +Пример из проекта: + +```csharp +// Выполняем SELECT и получаем потоковое чтение результата +await using var reader = await command.ExecuteReaderAsync(); + +// пока reader.ReadAsync() возвращает true — читаем очередную строку +while (await reader.ReadAsync()) +{ + int id = reader.GetInt32(reader.GetOrdinal("Id")); + string name = reader.GetString(reader.GetOrdinal("Name")); + + // Если в поле может быть NULL, проверяем это отдельно + string? description = reader.IsDBNull(reader.GetOrdinal("Description")) + ? null + : reader.GetString(reader.GetOrdinal("Description")); + + products.Add(new Product { Id = id, Name = name, Description = description }); +} +``` + +Методы `GetInt32`, `GetString`, `GetDecimal` читают значение нужного типа. +`GetOrdinal("Name")` возвращает номер колонки по её названию — читать по номеру +(`reader.GetString(0)`) тоже можно, но по названию понятнее и безопаснее +(если порядок колонок в запросе изменится, код продолжит работать). + +--- + +### 3.5 `SqlDataAdapter` и `DataTable` — disconnected (автономный) режим + +Мы уже посмотрели на **connected** подход: подключение открыто, `SqlDataReader` +читает строки по одной. Он быстрый, но подключение занято всё время чтения. + +**Disconnected (автономный) подход** работает иначе: + +1. `SqlDataAdapter` получает команду с запросом. +2. Сам открывает подключение (если оно закрыто), выполняет `SELECT` + и **забирает весь результат в память**. +3. Заполняет `DataTable` (или `DataSet`) — таблицу, которая живёт в памяти приложения. +4. **Сам закрывает подключение** (если открывал его). + +После этого вы работаете с `DataTable` как с обычной таблицей в памяти, +а база данных **полностью свободна** и не ждёт вас. + +Пример из проекта (`AdoNetShop/Data/ProductRepository.cs`): + +```csharp +public async Task GetProductsTableAsync() +{ + await using var connection = _connectionFactory.CreateConnection(); + + string query = "SELECT Id, Name, Price, StockQuantity FROM Products"; + await using var command = new SqlCommand(query, connection); + + // SqlDataAdapter — «мост» между источником данных и DataTable + using var adapter = new SqlDataAdapter(command); + var table = new DataTable("Products"); + + // Fill() сам откроет подключение, выполнит запрос, + // заполнит DataTable и закроет подключение. + // Fill() синхронный, поэтому оборачиваем его в Task.Run. + await Task.Run(() => adapter.Fill(table)); + + return table; +} +``` + +А дальше в `Program.cs` мы работаем с таблицей **без базы данных**: + +```csharp +DataTable table = await service.GetProductsTableAsync(); + +foreach (DataRow row in table.Rows) // никакого подключения к БД уже нет +{ + int id = (int)row["Id"]; + string name = (string)row["Name"]; + decimal price = (decimal)row["Price"]; + Console.WriteLine($"{id}\t{name}\t{price:C}"); +} +``` + +> **Когда что использовать?** +> * `SqlDataReader` — когда данных много (тысячи строк) и нужна экономия памяти +> и максимальная скорость. +> * `SqlDataAdapter` + `DataTable` — когда данных немного и с ними нужно удобно +> работать: сортировать, фильтровать, показывать в отчёте, передавать дальше. + +--- + +### 3.6 `SqlTransaction` — транзакция + +**Транзакция** — это группа SQL-операций, которые выполняются как одно целое: +**всё или ничего**. + +Классический пример из нашего проекта — **создание заказа**. Это несколько операций: + +``` +1. Создать заказ в таблице Orders +2. Для каждого товара заказа: + 2.1 Уменьшить остаток в таблице Products + 2.2 Добавить позицию в таблицу OrderItems +3. Зафиксировать (COMMIT) +``` + +Если на шаге 2.1 не хватает товара, то нельзя оставлять базу в таком состоянии: +заказ создан, а половина позиций — нет. Нужно отменить **всё**. Именно это +и обеспечивает транзакция. + +Реализация в проекте (`AdoNetShop/Data/OrderRepository.cs`): + +```csharp +await using var connection = _connectionFactory.CreateConnection(); +await connection.OpenAsync(); + +// 1) Начинаем транзакцию +await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync(); + +try +{ + // 2) Создаём заказ + await using var insertOrder = new SqlCommand(insertOrderQuery, connection, transaction); + insertOrder.Parameters.Add("@CustomerId", SqlDbType.Int).Value = order.CustomerId; + int newOrderId = Convert.ToInt32(await insertOrder.ExecuteScalarAsync()); + + foreach (var item in order.Items) + { + // 3) Уменьшаем остаток. В условии — проверка, что товара хватает: + // WHERE Id = @ProductId AND StockQuantity >= @Quantity + await using var updateStock = new SqlCommand(updateStockQuery, connection, transaction); + updateStock.Parameters.Add("@ProductId", SqlDbType.Int).Value = item.ProductId; + updateStock.Parameters.Add("@Quantity", SqlDbType.Int).Value = item.Quantity; + + int rowsAffected = await updateStock.ExecuteNonQueryAsync(); + if (rowsAffected == 0) + { + // Товара не хватает (или его нет). Бросаем исключение — + // код перейдёт в catch и сделает откат всей транзакции. + throw new Exception($"Недостаточно товара на складе для ProductId: {item.ProductId}"); + } + + // 4) Добавляем позицию заказа + await using var insertItem = new SqlCommand(insertItemQuery, connection, transaction); + // ... параметры ... + await insertItem.ExecuteNonQueryAsync(); + } + + // 5) Всё прошло успешно — фиксируем результат + await transaction.CommitAsync(); + return newOrderId; +} +catch (Exception ex) +{ + // 6) Любая ошибка — откатываем все изменения + await transaction.RollbackAsync(); + throw new Exception($"Ошибка при создании заказа. Транзакция отменена. Детали: {ex.Message}", ex); +} +``` + +Обратите внимание на важную деталь: **каждая команда внутри транзакции получает +третий параметр — `transaction`**. Если забыть его передать, SQL Server выдаст ошибку +«Invalid operation. The transaction associated with this command is not the connection's +active transaction». + +--- + +## 4. Connected vs Disconnected: в чём разница + +| | Connected (подключенный) | Disconnected (автономный) | +|---|---|---| +| Что используем | `SqlConnection` + `SqlCommand` + `SqlDataReader` | `SqlDataAdapter` + `DataTable` / `DataSet` | +| Когда открыто подключение | Всё время, пока читаем данные | Только на момент загрузки данных | +| Как читаем | По одной строке (`ReadAsync()`) | Сразу таблица целиком | +| Расход памяти | Минимальный (строка за строкой) | Вся выборка в памяти | +| Скорость на больших объёмах | Выше | Ниже (из-за накладных расходов на загрузку) | +| Можно работать без БД после загрузки | Нет | Да | +| Где в проекте | `GetAllAsync()`, `GetByIdAsync()`, `CreateOrderWithTransactionAsync()` | `GetProductsTableAsync()` (пункт меню 10) | + +Простое правило: **данных много → `SqlDataReader`; данных мало и нужно удобно +работать в памяти → `SqlDataAdapter` + `DataTable`.** + +--- + +## 5. Структура проекта + +``` +Artem/ +│ +├── README.md ← этот файл (теория + инструкции) +├── .gitignore +├── AdoNetShop.slnx ← файл решения Visual Studio / Rider +│ +├── AdoNetShop/ ← сам проект C# +│ ├── AdoNetShop.csproj ← файл проекта, здесь подключается Microsoft.Data.SqlClient +│ ├── Program.cs ← консольное меню и сценарии демонстрации +│ ├── appsettings.json ← строка подключения к БД (НЕ в коде!) +│ │ +│ ├── Models/ ← простые классы предметной области (POCO) +│ │ ├── Product.cs ← Id, Name, Description, Price, StockQuantity, CategoryId +│ │ ├── Category.cs ← Id, Name +│ │ ├── Customer.cs ← Id, Name, Email, Phone +│ │ ├── Order.cs ← Id, CustomerId, OrderDate, Status, TotalAmount +│ │ └── OrderItem.cs ← Id, OrderId, ProductId, Quantity, Price +│ │ +│ ├── Data/ ← ВЕСЬ РАБОТА С БАЗОЙ ЗДЕСЬ (ADO.NET) +│ │ ├── DbConnectionFactory.cs ← создание SqlConnection из строки подключения +│ │ ├── ProductRepository.cs ← CRUD товаров + SqlDataReader + SqlDataAdapter + Stored Procedure +│ │ ├── CategoryRepository.cs ← чтение категорий +│ │ ├── CustomerRepository.cs ← чтение клиентов +│ │ └── OrderRepository.cs ← создание заказа в транзакции (Commit / Rollback) +│ │ +│ └── Services/ +│ └── ShopService.cs ← небольшая прослойка: проверки и «прокси» к репозиториям +│ +└── database/ ← SQL-скрипты (выполняются по порядку) + ├── 01_create_database.sql ← создание базы данных AdoNetShop + ├── 02_create_tables.sql ← таблицы, ключи, ограничения CHECK, индексы + ├── 03_insert_data.sql ← тестовые данные + ├── 04_queries.sql ← примеры запросов + ├── 05_procedures.sql ← хранимые процедуры + └── 06_test.sql ← проверочные запросы (выполнять вручную) +``` + +**Почему такая структура?** Это классическое разделение ответственности: + +* **Models** — «что есть в магазине». Никакой логики, только данные. +* **Data (Repository)** — «как получить и сохранить данные». Здесь живёт + весь ADO.NET: `SqlConnection`, `SqlCommand`, `SqlDataReader`, транзакции. +* **Services** — «что можно сделать с магазином». Здесь простые проверки + данных перед сохранением. Бизнес-логика намеренно не усложнена. +* **Program** — «как это показываем пользователю». Консольное меню. + +Repository (репозиторий) — просто класс, в котором собраны SQL-запросы к одной +таблице. Это удобно: когда понадобится поменять SQL, вы открываете один файл. + +--- + +## 6. Структура базы данных + +``` +Categories (категории) + Id INT IDENTITY PRIMARY KEY + Name NVARCHAR(100) NOT NULL + ▲ + │ FK Products.CategoryId + │ +Products (товары) ─┐ + Id INT IDENTITY PRIMARY KEY│ 1 товар ──► много позиций заказа + Name NVARCHAR(200) NOT NULL │ + Description NVARCHAR(MAX) NULL │ + Price DECIMAL(18,2) NOT NULL CHECK (Price > 0) + StockQuantity INT NOT NULL CHECK (StockQuantity >= 0) + CategoryId INT NOT NULL │ + ▲ │ + │ │ FK OrderItems.ProductId + │ │ +Customers (клиенты) │ + Id INT IDENTITY PRIMARY KEY │ + Name NVARCHAR(100) NOT NULL │ + Email NVARCHAR(100) NOT NULL UNIQUE │ + Phone NVARCHAR(20) NULL │ + ▲ │ + │ FK Orders.CustomerId │ + │ │ +Orders (заказы) ─────────────────────┘ + Id INT IDENTITY PRIMARY KEY + CustomerId INT NOT NULL + OrderDate DATETIME2 NOT NULL DEFAULT GETDATE() + Status NVARCHAR(50) NOT NULL DEFAULT 'New' + TotalAmount DECIMAL(18,2) NOT NULL CHECK (TotalAmount >= 0) + +OrderItems (позиции заказа) + Id INT IDENTITY PRIMARY KEY + OrderId INT NOT NULL FK Orders(Id) ON DELETE CASCADE + ProductId INT NOT NULL FK Products(Id) + Quantity INT NOT NULL CHECK (Quantity > 0) + Price DECIMAL(18,2) NOT NULL CHECK (Price > 0) +``` + +Что здесь важно и почему: + +* `IDENTITY` — автоинкремент: база сама генерирует `Id`, приложение его не придумывает. +* `PRIMARY KEY` — гарантирует уникальность строк. +* `FOREIGN KEY` — гарантирует, что нельзя создать заказ для несуществующего клиента + или позицию для несуществующего товара. База защищает целостность данных. +* `CHECK` — ограничение на значения: цена не может быть отрицательной, + количество в заказе — нулевым или отрицательным. +* `UNIQUE` на `Customers.Email` — нельзя завести двух клиентов с одним email. +* `INDEX` на внешних ключах (`IX_Products_CategoryId`, `IX_Orders_CustomerId`, + `IX_OrderItems_OrderId`, `IX_OrderItems_ProductId`) — ускоряют `JOIN` и поиск. +* `DECIMAL(18,2)` для денег — **не** `FLOAT` и **не** `DOUBLE`: это единственный + тип, который хранит денежные значения без ошибок округления. +* `NVARCHAR`, а не `VARCHAR` — `NVARCHAR` поддерживает кириллицу и любые языки. + +--- + +## 7. Как установить SQL Server + +Есть три бесплатных варианта — выбирайте любой: + +### Вариант А: SQL Server Express (самый простой для учёбы) +1. Скачайте **SQL Server Express** с официального сайта Microsoft: + https://www.microsoft.com/ru-ru/products/sql-server/sql-server-express-downloads +2. При установке отметьте **Basic** (базовый) тип установки. +3. В мастере установки обязательно включите **средства управления** — + **SQL Server Management Tools (SSMS)**. Это программа, в которой вы будете + выполнять SQL-скрипты. +4. После установки запустите **SQL Server Configuration Manager** и проверьте, + что служба **SQL Server (SQLEXPRESS)** запущена (состояние «Running»). +5. Откройте SSMS и подключитесь к серверу. Сервер для Express по умолчанию + называется **`.\SQLEXPRESS`**. + +### Вариант Б: Developer Edition (полная версия, бесплатно для разработки) +Скачивается на той же странице Microsoft. После установки сервер по умолчанию +называется **`localhost`** (или имя вашего компьютера). + +### Вариант В: Docker (если установлен Docker) +```bash +docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Your_password123!" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest +``` + +> **Важно:** SQL Server — это отдельная программа-сервер. Пока он не установлен +> и не запущен, приложение не сможет подключиться, даже если код написан правильно. + +--- + +## 8. Как создать базу данных и выполнить SQL-скрипты + +### Способ 1: через SSMS (рекомендуется для учебной работы) + +1. Запустите **SQL Server Management Studio**. +2. В поле «Тип сервера» выберите **Database Engine**. +3. В поле «Имя сервера» введите `localhost` (или `.\SQLEXPRESS` для Express, + или `localhost\SQLEXPRESS` — этот вариант подходит всегда). +4. Аутентификация: **Windows Authentication** — вводить логин и пароль не нужно. +5. Нажмите **Connect**. Если увидели окно с деревом серверов — вы подключились. + +Дальше откройте каждый файл из папки `database` через **File → Open → File** +и нажмите **Execute** (или клавишу `F5`). **Порядок важен**, выполняйте строго +по номерам: + +| Шаг | Файл | Что делает | +|---|---|---| +| 1 | `01_create_database.sql` | Создаёт базу данных `AdoNetShop` | +| 2 | `02_create_tables.sql` | Создаёт таблицы, ключи, ограничения, индексы | +| 3 | `03_insert_data.sql` | Заполняет таблицы тестовыми данными | +| 4 | `04_queries.sql` | Примеры запросов (для понимания, можно не выполнять) | +| 5 | `05_procedures.sql` | Создаёт хранимые процедуры | +| 6 | `06_test.sql` | Проверочные запросы (для самопроверки, можно не выполнять) | + +Скрипты написаны «безопасно»: `01` использует `IF NOT EXISTS`, `02` создаёт +таблицы только если их нет, `05` удаляет процедуру перед созданием. Поэтому +скрипты можно запускать повторно — ошибки «уже существует» не будет. + +### Способ 2: через командную строку (sqlcmd) + +```bash +# Способ с Windows-аутентификацией +sqlcmd -S localhost -E -i "database\01_create_database.sql" +sqlcmd -S localhost -E -d AdoNetShop -i "database\02_create_tables.sql" +sqlcmd -S localhost -E -d AdoNetShop -i "database\03_insert_data.sql" + +# Для SQL Server Express +sqlcmd -S .\SQLEXPRESS -E -i "database\01_create_database.sql" +``` + +### Проверка, что всё создалось + +В SSMS подключитесь к базе `AdoNetShop` (в списке слева) и выполните: + +```sql +SELECT * FROM Categories; +SELECT * FROM Products; +SELECT * FROM Customers; +``` + +Вы должны увидеть: 3 категории, 6 товаров, 2 клиентов. + +--- + +## 9. Как изменить строку подключения (Connection String) + +Строка подключения **не зашита в код**. Она лежит в файле +`AdoNetShop/appsettings.json`: + +```json +{ + "ConnectionStrings": { + "DefaultConnection": "Server=localhost;Database=AdoNetShop;Trusted_Connection=True;TrustServerCertificate=True;" + } +} +``` + +Читает её класс `DbConnectionFactory` (`AdoNetShop/Data/DbConnectionFactory.cs`): + +```csharp +var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + +_connectionString = configuration.GetConnectionString("DefaultConnection"); +``` + +### Что означают параметры строки подключения + +| Параметр | Значение в примере | Что это | +|---|---|---| +| `Server` | `localhost` | Адрес сервера SQL Server | +| `Database` | `AdoNetShop` | Имя базы данных | +| `Trusted_Connection` | `True` | Использовать Windows-аутентификацию (логин/пароль не нужны) | +| `TrustServerCertificate` | `True` | Доверять самоподписанному SSL-сертификату SQL Server (нужно для свежих версий `Microsoft.Data.SqlClient`) | + +### Если SQL Server работает как **именованный экземпляр** + +Так бывает после установки SQL Server Express — сервер называется `SQLEXPRESS`. +В строке подключения именованный экземпляр пишется через обратный слэш `\`: + +```json +"DefaultConnection": "Server=.\\SQLEXPRESS;Database=AdoNetShop;Trusted_Connection=True;TrustServerCertificate=True;" +``` + +> **Про обратный слэш и JSON.** В JSON символ `\` — начало escape-последовательности, +> поэтому обратный слэш записывается **двойным**: `\\`. +> В строке подключения это означает один-единственный обратный слэш. + +Варианты, которые тоже работают: + +``` +Server=localhost\SQLEXPRESS — если хотите быть явными +Server=(local)\SQLEXPRESS — эквивалент +Server=.\MSSQLSERVER — именованный экземпляр основного сервера +``` + +### Если нужна аутентификация SQL Server (логин и пароль) + +```json +"DefaultConnection": "Server=localhost;Database=AdoNetShop;User Id=sa;Password=ТвойПароль;TrustServerCertificate=True;" +``` + +> Внимание: пароль в `appsettings.json` — это «учебный» вариант. В настоящем проекте +> такие данные хранят в переменных окружения или в защищённом хранилище секретов. +> Для лабораторной работы достаточно `appsettings.json` — главное, что пароль +> **не зашит в исходный код**, а вынесен в конфигурацию. + +### Как проверить, что подключение работает + +```bash +# В SSMS: Server → Connect → Database Engine, имя сервера как в appsettings.json +# Либо в командной строке проекта (Visual Studio / Rider → Terminal): +cd AdoNetShop +dotnet run +``` + +Если в меню выбрать пункт `1. Список товаров` и вы увидите таблицу с товарами — +подключение работает корректно. + +--- + +## 10. Как запустить проект + +### Что нужно +* [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) — проверьте командой `dotnet --version` +* Запущенный SQL Server (см. раздел 7) +* Выполненные скрипты `01`, `02`, `03`, `05` (см. раздел 8) +* Правильная строка подключения в `appsettings.json` (см. раздел 9) + +### Запуск из командной строки + +```bash +cd AdoNetShop + +# Восстановить пакеты (обычно делается автоматически) +dotnet restore + +# Собрать проект +dotnet build + +# Запустить +dotnet run +``` + +### Запуск из Visual Studio + +1. Откройте файл решения `AdoNetShop.slnx` (или `AdoNetShop.csproj`). +2. Убедитесь, что в качестве стартового проекта выбран `AdoNetShop`. +3. Нажмите `Ctrl + F5` (запуск без отладки) или `F5` (с отладкой). +4. Для отладки поставьте точки останова в `ProductRepository.GetAllAsync` и + `OrderRepository.CreateOrderWithTransactionAsync` — очень полезно для защиты. + +### Запуск из Rider + +Откройте папку проекта в Rider — он сам определит `.csproj`. Нажмите `Shift + F10`. + +### Что должно появиться + +``` +================================ + ADO.NET SHOP +================================ +1. Список товаров +2. Найти товар по ID +3. Добавить товар +4. Изменить товар +5. Удалить товар +6. Список категорий +7. Список клиенты +8. Создать заказ (Демонстрация Transaction) +9. Просмотреть заказы +10. Демонстрация DataAdapter (Disconnected mode) +11. Демонстрация Stored Procedures +0. Выход +================================ +``` + +--- + +## 11. Что умеет программа (демонстрация преподавателю) + +Каждый пункт меню — это отдельная демонстрация элемента ADO.NET. + +### Пункт 1. Список товаров → `SqlDataReader` + `ExecuteReaderAsync` + +Вызывает `ProductRepository.GetAllAsync()`. Показывает потоковое чтение +результата `SELECT` через `SqlDataReader`. + +**Что говорить:** «Открывается соединение, выполняется SELECT, и результат +читается построчно объектом `SqlDataReader`, пока `ReadAsync()` возвращает `true`». + +### Пункт 2. Найти товар по ID → параметризованный `SqlCommand` + +Вызывает `GetByIdAsync(id)`. Демонстрирует `WHERE Id = @Id` + `SqlParameter`. + +**Что говорить:** «Пользовательский ввод никогда не подставляется в текст запроса +через конкатенацию — только через параметры. Это защита от SQL Injection». + +### Пункт 3. Добавить товар → `ExecuteScalarAsync` + +Вызывает `CreateAsync(product)`. Вставляет строку и получает сгенерированный +базой `Id` через `SELECT SCOPE_IDENTITY()`. + +**Что говорить:** «`ExecuteScalarAsync` возвращает одно значение — в нашем случае +это `Id` нового товара, который сгенерировал `IDENTITY`». + +### Пункт 4. Изменить товар → `ExecuteNonQueryAsync` + +Вызывает `UpdateAsync(product)`. Возвращает количество затронутых строк. + +### Пункт 5. Удалить товар → `ExecuteNonQueryAsync` + +Вызывает `DeleteAsync(id)`. Обратите внимание: если на товар ссылаются строки +в `OrderItems`, база не даст удалить такой товар (Foreign Key). Это тоже можно +показать как защиту целостности данных. + +### Пункт 6. Категории / Пункт 7. Клиенты → простые `SELECT` + +Чтение справочных таблиц, которые нужны, чтобы заполнить выпадающие списки +при добавлении товара и оформлении заказа. + +### Пункт 8. Создать заказ → `SqlTransaction`, `Commit`, `Rollback` ★ + +**Главная демонстрация лабораторной.** Создаёт заказ целиком в одной транзакции. + +**Как проверить откат:** +1. Выберите клиента (например, `1`). +2. Добавьте товар `1` в количестве `2` — заказ создастся успешно. +3. Добавьте товар, которого на складе мало, в количестве `9999`. +4. Программа выведет ошибку «Недостаточно товара на складе». +5. Откройте SSMS и выполните `SELECT * FROM Orders;` — нового заказа не будет. + Транзакция откатила **всё**: и заказ, и позиции, и изменения остатков. + +**Что говорить:** «Транзакция гарантирует принцип "все или ничего". Нельзя получить +ситуацию, когда заказ создан, а товары к нему — нет». + +### Пункт 9. Просмотреть заказы → чтение результата + +Показывает созданные заказы: клиент, сумма, дата, статус. + +### Пункт 10. DataAdapter demo → `SqlDataAdapter` + `DataTable` (disconnected) + +Вызывает `GetProductsTableAsync()`. Показывает, что данные загрузились +в таблицу в памяти и с ними можно работать **без подключения к базе**. + +**Что говорить:** «`SqlDataAdapter` сам открыл подключение, скачал данные в +`DataTable` и закрыл подключение. Дальше мы работаем с обычной таблицей в памяти — +база данных в этот момент свободна для других пользователей». + +### Пункт 11. Stored Procedures demo → `CommandType.StoredProcedure` + +Вызывает хранимые процедуры `dbo.GetProducts` и `dbo.UpdateProductPrice`. + +**Как проверить обработку ошибки:** введите цену `-5` — процедура вызовет +`RAISERROR`, приложение перехватит `SqlException` и покажет сообщение. + +**Что говорить:** «Хранимые процедуры — это SQL-код, который хранится на сервере. +Из C# мы вызываем их по имени, а не пишем запрос текстом. Основная бизнес-логика +при этом остаётся в приложении — процедуры здесь только для демонстрации». + +--- + +## 12. Обработка ошибок и валидация + +### Проверка пользовательского ввода (в `Program.cs` и `ShopService.cs`) + +| Что проверяем | Где | +|---|---| +| ID — целое число | `int.TryParse(...)` перед вызовом репозитория | +| Цена больше нуля | `ShopService.CreateProductAsync`, `Product.Price` | +| Количество на складе не отрицательное | `ShopService.CreateProductAsync`, `StockQuantity` | +| Email не пустой | при добавлении клиента | +| Количество товара в заказе больше нуля | при оформлении заказа | +| Товар существует и его хватает | `ShopService.CreateOrderAsync` до открытия транзакции | + +Пример: + +```csharp +if (product.Price <= 0) + throw new ArgumentException("Price must be greater than 0"); +if (product.StockQuantity < 0) + throw new ArgumentException("Stock cannot be negative"); +``` + +### Обработка ошибок в приложении + +В главном цикле `Program.cs` все пункты меню выполняются внутри `try/catch`: + +```csharp +try +{ + switch (input) { /* ... */ } +} +catch (Exception ex) +{ + Console.WriteLine($"[ОШИБКА]: {ex.Message}"); + if (ex.InnerException != null) + Console.WriteLine($"[ДЕТАЛИ]: {ex.InnerException.Message}"); +} +``` + +Такой подход выбран сознательно: **для учебного проекта исключения не скрываются +молча** — пользователь видит, что произошло. В продакшене ошибки логировали бы +в файл, а пользователю показывали бы нейтральное сообщение. + +Дополнительно ошибки от самой СУБД обрабатываются отдельно. `SqlException` — +это исключение SQL Server, в нём есть номер ошибки и текст от сервера: + +```csharp +catch (SqlException ex) +{ + Console.WriteLine($"Ошибка SQL Server (код {ex.Number}): {ex.Message}"); + return false; +} +``` + +--- + +## 13. Какие файлы нужно изучить в первую очередь + +Если преподаватель спросит «покажи, где у тебя ADO.NET», открывайте эти файлы +в таком порядке: + +### 1. `AdoNetShop/Data/DbConnectionFactory.cs` — подключение +Класс, который создаёт `SqlConnection`. Здесь видно, что строка подключения +берётся из `appsettings.json`. + +### 2. `AdoNetShop/Data/ProductRepository.cs` — основные элементы ADO.NET +**Самый важный файл лабораторной.** В нём собрано всё сразу: + +| Метод | Что показывает | +|---|---| +| `GetAllAsync()` | `SqlConnection.OpenAsync()`, `SqlCommand`, `ExecuteReaderAsync()`, `SqlDataReader` | +| `GetByIdAsync(id)` | `SqlParameter`, параметризованный запрос | +| `CreateAsync(product)` | `ExecuteScalarAsync()`, `SCOPE_IDENTITY()` | +| `UpdateAsync(product)` | `ExecuteNonQueryAsync()` для `UPDATE` | +| `DeleteAsync(id)` | `ExecuteNonQueryAsync()` для `DELETE` | +| `GetProductsTableAsync()` | `SqlDataAdapter` + `DataTable` (disconnected) | +| `GetProductsByStoredProcedureAsync(...)` | `CommandType.StoredProcedure` | +| `UpdatePriceViaStoredProcedureAsync(...)` | `StoredProcedure` + `UPDATE`, `SqlException` | + +### 3. `AdoNetShop/Data/OrderRepository.cs` — транзакция +Метод `CreateOrderWithTransactionAsync()`: `BeginTransactionAsync()` → `CommitAsync()` +/ `RollbackAsync()`. Показывает, что все команды внутри транзакции получают +третий параметр `transaction`. + +### 4. `AdoNetShop/appsettings.json` — конфигурация +Строка подключения вынесена из кода. Покажите преподавателю, что в исходниках +нет ни логинов, ни паролей. + +### 5. `database/02_create_tables.sql` — схема БД +`PRIMARY KEY`, `FOREIGN KEY`, `CHECK`, индексы. Свяжите таблицы с классами +из папки `Models/`. + +### 6. `database/05_procedures.sql` — хранимые процедуры +Три процедуры, которые вызываются из `ProductRepository`. + +--- + +## 14. Частые проблемы и их решения + +| Симптом | Причина | Что сделать | +|---|---|---| +| `A network-related or instance-specific error occurred` | SQL Server не запущен или неверный адрес сервера | Запустите службу SQL Server. Проверьте `Server` в `appsettings.json`. Для Express должно быть `.\SQLEXPRESS` | +| `Login failed for user` | Неверный режим аутентификации | Попробуйте `Trusted_Connection=True` (Windows Auth) вместо `User Id=sa;Password=...` | +| `Certificate is not trusted` | Не настроен SSL-сертификат SQL Server | Добавьте `TrustServerCertificate=True` в строку подключения | +| `Cannot open database "AdoNetShop"` | База не создана | Выполните `01_create_database.sql` в SSMS | +| `Invalid object name 'Products'` | Таблицы не созданы | Выполните `02_create_tables.sql` | +| `Could not find file appsettings.json` | Запустили из другой папки | Запускайте команду из папки `AdoNetShop`, где лежит `.csproj` | +| `The model ... context has been changed` | Изменили модель после миграции | Не применимо к этому проекту — здесь **нет** EF и миграций | +| Ошибка `PK_...` при `INSERT` | Попытка вставить дубликат | Например, два клиента с одинаковым Email (`UNIQUE`) | +| Не компилируется проект | Не установлен .NET 8 SDK | Установите SDK и выполните `dotnet restore` | + +--- + +## Краткая памятка: 12 элементов ADO.NET, которые демонстрирует проект + +| # | Элемент ADO.NET | Где найти | +|---|---|---| +| 1 | `SqlConnection` (создание) | `Data/DbConnectionFactory.cs` | +| 2 | `OpenAsync()` / `Close()` / `using` | все репозитории | +| 3 | `SqlCommand` | `Data/ProductRepository.cs` | +| 4 | `ExecuteReaderAsync()` | `GetAllAsync` | +| 5 | `ExecuteScalarAsync()` | `CreateAsync` | +| 6 | `ExecuteNonQueryAsync()` | `UpdateAsync`, `DeleteAsync` | +| 7 | `SqlParameter` | `GetByIdAsync`, `CreateAsync` | +| 8 | `SqlDataReader` | `GetAllAsync` | +| 9 | `SqlDataAdapter` + `DataTable` | `GetProductsTableAsync` | +| 10 | `SqlTransaction`, `Commit`, `Rollback` | `Data/OrderRepository.cs` | +| 11 | `CommandType.StoredProcedure` | `GetProductsByStoredProcedureAsync` | +| 12 | `SqlException` | `UpdatePriceViaStoredProcedureAsync` | + +--- + +*Проект создан в учебных целях для лабораторной работы по технологии ADO.NET. +ADO.NET используется как основной и единственный способ работы с базой данных.* \ No newline at end of file diff --git a/database/05_procedures.sql b/database/05_procedures.sql index 3ebafdb..0daedd4 100644 --- a/database/05_procedures.sql +++ b/database/05_procedures.sql @@ -1,38 +1,73 @@ -- 05_procedures.sql --- Создание хранимых процедур +-- Хранимые процедуры (Stored Procedures). +-- ВАЖНО: основная бизнес-логика остаётся в C# (ADO.NET). +-- Здесь только простые процедуры, чтобы показать, как ADO.NET +-- умеет вызывать хранимую процедуру через CommandType.StoredProcedure. USE [AdoNetShop]; GO --- Процедура для получения товаров по категории -CREATE OR ALTER PROCEDURE GetProductsByCategory - @CategoryId INT +-- 1) Получить все товары (или только товары одной категории). +-- Если @CategoryId = NULL, возвращаются все товары. +IF OBJECT_ID('dbo.GetProducts', 'P') IS NOT NULL + DROP PROCEDURE dbo.GetProducts; +GO + +CREATE PROCEDURE dbo.GetProducts + @CategoryId INT = NULL AS BEGIN SET NOCOUNT ON; - + SELECT Id, Name, Description, Price, StockQuantity, CategoryId FROM Products - WHERE CategoryId = @CategoryId; + WHERE @CategoryId IS NULL OR CategoryId = @CategoryId + ORDER BY Name; END GO --- Процедура для безопасного обновления цены -CREATE OR ALTER PROCEDURE UpdateProductPrice - @ProductId INT, - @NewPrice DECIMAL(18,2) +-- 2) Получить один товар по идентификатору. +IF OBJECT_ID('dbo.GetProductById', 'P') IS NOT NULL + DROP PROCEDURE dbo.GetProductById; +GO + +CREATE PROCEDURE dbo.GetProductById + @ProductId INT AS BEGIN SET NOCOUNT ON; - + + SELECT Id, Name, Description, Price, StockQuantity, CategoryId + FROM Products + WHERE Id = @ProductId; +END +GO + +-- 3) Изменить цену товара с проверкой входных данных. +-- Простой пример того, как часть проверки можно вынести на сервер БД. +IF OBJECT_ID('dbo.UpdateProductPrice', 'P') IS NOT NULL + DROP PROCEDURE dbo.UpdateProductPrice; +GO + +CREATE PROCEDURE dbo.UpdateProductPrice + @ProductId INT, + @NewPrice DECIMAL(18, 2) +AS +BEGIN + SET NOCOUNT ON; + IF @NewPrice <= 0 BEGIN - RAISERROR ('Цена должна быть больше нуля.', 16, 1); + -- RAISERROR: сами мы формируем сообщение об ошибке и отдаём его клиенту + RAISERROR ('Цена товара должна быть больше нуля.', 16, 1); RETURN; END UPDATE Products SET Price = @NewPrice WHERE Id = @ProductId; + + IF @@ROWCOUNT = 0 + RAISERROR ('Товар с таким Id не найден.', 16, 1); END -GO +GO \ No newline at end of file diff --git a/database/06_test.sql b/database/06_test.sql index a27d18c..d97a7d2 100644 --- a/database/06_test.sql +++ b/database/06_test.sql @@ -1,19 +1,74 @@ -- 06_test.sql --- Тестовый скрипт (выполнять вручную для проверки базы) +-- Проверочные запросы. Выполняйте вручную после 01-05 скриптов. USE [AdoNetShop]; GO +-- ============ Таблицы ============ SELECT * FROM Categories; SELECT * FROM Products; SELECT * FROM Customers; +SELECT * FROM Orders; +SELECT * FROM OrderItems; --- Тест процедуры -EXEC GetProductsByCategory @CategoryId = 1; +-- ============ Примеры SELECT-запросов из приложения ============ --- Тест обновления цены (успешный) -EXEC UpdateProductPrice @ProductId = 1, @NewPrice = 1450.00; -SELECT * FROM Products WHERE Id = 1; +-- 1. Все товары (демонстрация SqlDataReader) +SELECT Id, Name, Description, Price, StockQuantity, CategoryId +FROM Products; +GO --- Тест обновления цены (ошибка) --- EXEC UpdateProductPrice @ProductId = 1, @NewPrice = -10.00; +-- 2. Один товар по Id (демонстрация параметризованного SqlCommand) +DECLARE @Id INT = 1; +SELECT Id, Name, Description, Price, StockQuantity, CategoryId +FROM Products +WHERE Id = @Id; +GO + +-- 3. Товары с названием категории (JOIN) +SELECT p.Id, p.Name, p.Price, p.StockQuantity, c.Name AS CategoryName +FROM Products p +JOIN Categories c ON p.CategoryId = c.Id +ORDER BY p.Name; +GO + +-- ============ Проверка хранимых процедур ============ + +-- Все товары +EXEC dbo.GetProducts; +GO + +-- Товары только 1-й категории +EXEC dbo.GetProducts @CategoryId = 1; +GO + +-- Один товар +EXEC dbo.GetProductById @ProductId = 1; +GO + +-- Успешное изменение цены +EXEC dbo.UpdateProductPrice @ProductId = 1, @NewPrice = 1450.00; +SELECT Id, Name, Price FROM Products WHERE Id = 1; +GO + +-- Ожидаемая ошибка: цена <= 0 (процедура сама вызовет RAISERROR) +EXEC dbo.UpdateProductPrice @ProductId = 1, @NewPrice = -10.00; +GO + +-- ============ Проверка ограничений (CHECK) ============ + +-- Ожидаемая ошибка: Price > 0 +INSERT INTO Products (Name, Price, StockQuantity, CategoryId) +VALUES ('Тестовый товар', -5, 1, 1); +GO + +-- Ожидаемая ошибка: StockQuantity >= 0 +UPDATE Products SET StockQuantity = -1 WHERE Id = 1; +GO + +-- ============ Проверка целостности связей (FOREIGN KEY) ============ + +-- Ожидаемая ошибка: категории 999 не существует +INSERT INTO Products (Name, Price, StockQuantity, CategoryId) +VALUES ('Товар без категории', 100, 1, 999); +GO \ No newline at end of file