# AdoNetShop — лабораторная работа по ADO.NET
Небольшой интернет-магазин на C#, который работает с Microsoft SQL Server через ADO.NET.
ORM здесь не используются: ни Entity Framework, ни Dapper. Все запросы к базе написаны
вручную, поэтому в коде видно, как приложение общается с базой данных.
## Что нужно для запуска
1. .NET 8 SDK. Проверить: `dotnet --version`
2. Microsoft SQL Server (Developer, Express или LocalDB).
3. SQL Server Management Studio (SSMS) — программа, в которой выполняют SQL-скрипты.
## Как запустить
**Шаг 1. Создайте базу данных.**
Откройте SSMS, подключитесь к своему серверу и выполните файлы из папки `database`
строго по порядку:
| Файл | Что делает |
|---|---|
| `01_create_database.sql` | создаёт базу `AdoNetShop` |
| `02_create_tables.sql` | создаёт таблицы, ключи, ограничения, индексы |
| `03_insert_data.sql` | добавляет тестовые данные |
| `05_procedures.sql` | создаёт хранимые процедуры |
Файлы `04_queries.sql` и `06_test.sql` — это примеры запросов для проверки.
Их можно не выполнять.
Все скрипты можно запускать повторно: старые данные удаляются, новые добавляются
с теми же номерами.
**Шаг 2. Проверьте строку подключения.**
Файл `AdoNetShop/appsettings.json`:
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=AdoNetShop;Trusted_Connection=True;TrustServerCertificate=True;"
}
}
```
Если сервер называется иначе, поменяйте только параметр `Server`:
```
Server=localhost обычный SQL Server
Server=.\\SQLEXPRESS SQL Server Express
Server=(localdb)\\MSSQLLocalDB LocalDB
```
В JSON обратный слэш пишется дважды: `\\` вместо `\`.
Подробнее про каждый параметр строки подключения — в разделе ниже.
**Шаг 3. Запустите приложение.**
```bash
cd AdoNetShop
dotnet run
```
**Шаг 4. Пользуйтесь меню.**
Если база создана неправильно или сервер выключен, программа не сломается:
она покажет понятное сообщение об ошибке и продолжит работать.
## Что такое ADO.NET
ADO.NET — это набор классов, через которые программа на C# общается с базой данных.
Без него приложение не знает, как отправить запрос в SQL Server и как получить ответ.
Схема выглядит так:
```
Приложение на C# <--> ADO.NET <--> SQL Server
наш код переводчик где лежат данные
```
### Зачем он нужен
- Можно писать любой SQL, какой поддерживает база. ORM так не умеет без обходных путей.
- Работает быстро: нет лишних проверок и отслеживания изменений, которые делают ORM.
- Показывает, как устроена работа с базой на самом деле. Любой ORM внутри делает
почти то же самое, только прячет это от вас.
### Что такое провайдер
ADO.NET сам не ходит в базу. Для каждой СУБД есть отдельная библиотека —
это и есть провайдер:
| База | Провайдер |
|---|---|
| SQL Server | `Microsoft.Data.SqlClient` |
| PostgreSQL | `Npgsql` |
| MySQL | `MySqlConnector` |
| SQLite | `Microsoft.Data.Sqlite` |
В нашем проекте используется `Microsoft.Data.SqlClient`. Он подключается одной
строкой в файле проекта:
```xml
```
## Главные классы ADO.NET
### SqlConnection — подключение к базе
`SqlConnection` — это подключение к серверу. Создаётся из строки подключения.
```csharp
await using var connection = new SqlConnection(connectionString);
// Открываем подключение
await connection.OpenAsync();
```
Подключение нужно открыть перед запросом и закрыть после. Закрывать вручную не нужно:
`await using` сделает это сам, даже если внутри случилась ошибка.
Создаёт подключения класс `Data/DbConnectionFactory.cs`. Он читает строку подключения
из `appsettings.json`, поэтому в коде нет ни логинов, ни паролей.
### SqlCommand — команда
`SqlCommand` хранит сам запрос: текст SQL или имя хранимой процедуры.
```csharp
await using var command = new SqlCommand("SELECT Id, Name FROM Products", connection);
```
Выполнить команду можно тремя способами:
| Способ | Что делает | Когда нужен |
|---|---|---|
| `ExecuteReaderAsync()` | возвращает строки результата | когда читаем `SELECT` |
| `ExecuteScalarAsync()` | возвращает одно значение | когда нужно одно число, например новый `Id` |
| `ExecuteNonQueryAsync()` | возвращает число изменённых строк | для `INSERT`, `UPDATE`, `DELETE` |
### SqlParameter — параметры и защита от SQL Injection
Пользовательский ввод нельзя вставлять в текст запроса склейкой строк.
Так делать нельзя:
```csharp
// Неправильно
string query = "SELECT * FROM Products WHERE Name = '" + name + "'";
```
Если пользователь введёт `' OR '1'='1`, запрос станет
`WHERE Name = '' OR '1'='1'` и вернёт все товары. Ещё хуже — можно подставить
`'; DROP TABLE Products; --` и удалить таблицу. Это и называется SQL Injection.
Правильно — передавать значения через параметры:
```csharp
// Правильно
command.Parameters.Add("@Id", SqlDbType.Int).Value = id;
```
Значение уходит на сервер отдельно от текста запроса, поэтому подставить свой SQL
через него невозможно. `SqlDbType.Int` указывает тип заранее: так сервер понимает
данные быстрее и правильнее, чем при `AddWithValue`.
Во всём проекте текст запроса — это неизменяемая строка, а вставляются только
параметры. Ни одного такого склеивания в коде нет.
### SqlDataReader — чтение строк
`SqlDataReader` возвращает строки по одной. Он работает только вперёд, поэтому
подходит для больших таблиц: память не переполняется.
```csharp
// Выполняем SELECT и читаем результат построчно
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string name = reader.GetString(reader.GetOrdinal("Name"));
decimal price = reader.GetDecimal(reader.GetOrdinal("Price"));
// Если поле может быть пустым, проверяем это
string? description = reader.IsDBNull(reader.GetOrdinal("Description"))
? null
: reader.GetString(reader.GetOrdinal("Description"));
}
```
`ReadAsync()` возвращает `true`, пока есть строки. Пока открыт `reader`, другие
команды на этом же подключении выполнять нельзя.
### SqlDataAdapter и DataTable — работа без базы
Иногда удобно забрать все данные в память и закрыть подключение. Это делает
`SqlDataAdapter`:
```csharp
await using var connection = _connectionFactory.CreateConnection();
await using var command = new SqlCommand("SELECT Id, Name, Price FROM Products", connection);
using var adapter = new SqlDataAdapter(command);
var table = new DataTable();
// Сам открывает подключение, забирает данные и закрывает подключение
await Task.Run(() => adapter.Fill(table));
```
`Fill()` — синхронный метод, поэтому он обёрнут в `Task.Run`.
Дальше с таблицей можно работать как с обычной таблицей в памяти. База при этом
свободна и не ждёт:
```csharp
foreach (DataRow row in table.Rows)
{
int id = (int)row["Id"];
string name = (string)row["Name"];
decimal price = (decimal)row["Price"];
Console.WriteLine($"{id} {name} {price:C}");
}
```
### SqlTransaction — всё или ничего
Транзакция — это несколько запросов, которые выполняются вместе. Если хотя бы один
провалился, отменяются все.
Создание заказа как раз такой случай. Заказ состоит из нескольких действий:
```
создать заказ
для каждого товара:
уменьшить остаток на складе
добавить позицию в заказ
```
Если второго товара не оказалось на складе, нельзя оставлять базу в таком виде:
заказ есть, а позиций нет. Транзакция отменяет всё целиком.
```csharp
await using var transaction = (SqlTransaction)await connection.BeginTransactionAsync();
try
{
// создаём заказ
// списываем остатки и добавляем позиции
await transaction.CommitAsync(); // всё прошло успешно
}
catch (Exception ex)
{
await transaction.RollbackAsync(); // отменяем все изменения
throw;
}
```
Важная деталь: каждая команда внутри транзакции получает третий параметр —
саму транзакцию. Если его забыть, сервер вернёт ошибку.
```csharp
await using var command = new SqlCommand(query, connection, transaction);
```
## Connected и Disconnected — разница
| | Connected | Disconnected |
|---|---|---|
| Классы | `SqlConnection`, `SqlCommand`, `SqlDataReader` | `SqlDataAdapter`, `DataTable` |
| Подключение | открыто всё время чтения | открыто только на время загрузки |
| Чтение | по одной строке | сразу вся таблица |
| Память | тратится мало | тратится на всю выборку |
| Когда применять | много данных | мало данных и нужна удобная работа с ними |
Простое правило: данных много — берём `SqlDataReader`, данных мало и нужно
покрутить их в памяти — берём `SqlDataAdapter`.
## Структура проекта
```
Artem/
├── README.md
├── AdoNetShop.slnx файл решения
│
├── AdoNetShop/ сам проект
│ ├── Program.cs консольное меню
│ ├── appsettings.json строка подключения
│ ├── AdoNetShop.csproj файл проекта
│ │
│ ├── Models/ данные магазина, без логики
│ │ ├── Product.cs
│ │ ├── Category.cs
│ │ ├── Customer.cs
│ │ ├── Order.cs
│ │ └── OrderItem.cs
│ │
│ ├── Data/ вся работа с базой
│ │ ├── DbConnectionFactory.cs создаёт SqlConnection
│ │ ├── ProductRepository.cs CRUD товаров, DataAdapter, процедуры
│ │ ├── CategoryRepository.cs чтение категорий
│ │ ├── CustomerRepository.cs чтение клиентов
│ │ └── OrderRepository.cs заказ в транзакции
│ │
│ └── Services/
│ └── ShopService.cs простые проверки перед сохранением
│
└── database/ скрипты для SQL Server
├── 01_create_database.sql
├── 02_create_tables.sql
├── 03_insert_data.sql
├── 04_queries.sql
├── 05_procedures.sql
└── 06_test.sql
```
Разделение простое: `Models` — что есть в магазине, `Data` — как это лежит в базе,
`Services` — что с этим можно сделать, `Program` — как это показываем пользователю.
## Таблицы в базе
| Таблица | Что хранит |
|---|---|
| `Categories` | категории товаров: ноутбуки, смартфоны, аксессуары |
| `Products` | товары: название, описание, цена, остаток, категория |
| `Customers` | клиенты: имя, email, телефон |
| `Orders` | заказы: кто, когда, статус, сумма |
| `OrderItems` | что именно заказали: товар, количество, цена |
Что настроено в таблицах и зачем:
- `IDENTITY` — база сама выдаёт `Id`.
- `PRIMARY KEY` — строки не повторяются.
- `FOREIGN KEY` — нельзя создать заказ для несуществующего клиента.
- `CHECK` — цена больше нуля, количество в заказе больше нуля.
- `UNIQUE` — нельзя завести двух клиентов с одним email.
- `INDEX` на внешних ключах — быстрее работают `JOIN`.
- `DECIMAL(18,2)` для денег — не `FLOAT`, потому что он даёт ошибки округления.
- `NVARCHAR` вместо `VARCHAR` — чтобы работала кириллица.
## Что умеет программа
В меню 12 пунктов. Каждый показывает свой элемент ADO.NET.
| Пункт | Что делает | Что показывает |
|---|---|---|
| 1 | Список товаров | `SqlDataReader`, `ExecuteReaderAsync` |
| 2 | Найти товар по номеру | `SqlParameter`, параметризованный запрос |
| 3 | Добавить товар | `ExecuteScalarAsync`, получение нового `Id` |
| 4 | Изменить товар | `ExecuteNonQueryAsync` для `UPDATE` |
| 5 | Удалить товар | `ExecuteNonQueryAsync` для `DELETE` |
| 6 | Список категорий | простой `SELECT` |
| 7 | Список клиентов | простой `SELECT` |
| 8 | Создать заказ | транзакция: `Commit` или `Rollback` |
| 9 | Список заказов | чтение результата запроса |
| 10 | DataAdapter | `SqlDataAdapter` и `DataTable` |
| 11 | Stored Procedures | `CommandType.StoredProcedure`, ошибка `RAISERROR` |
| 12 | Откат транзакции | наглядный `Rollback` |
### Пункт 8. Заказ и транзакция
Программа спрашивает клиента и список товаров, затем в одной транзакции создаёт заказ,
списывает остатки и добавляет позиции. Если товара не хватает, база остаётся
без изменений: заказ не появляется, остатки прежние.
### Пункт 12. Наглядный откат
Этот пункт специально для защиты. Метод `CreateOrderWithRollbackDemoAsync()`
делает так:
1. Открывает транзакцию.
2. Создаёт заказ.
3. Успешно списывает две штуки первого товара.
4. Падает с ошибкой.
5. Выполняет `Rollback`.
6. Показывает остатки заново.
В консоли видно: списание прошло успешно, но после отката остатки такие же,
как были до операции. Это и доказывает главное свойство транзакции.
### Пункт 11. Хранимые процедуры
Вызываются процедуры `dbo.GetProducts`, `dbo.GetProductById`, `dbo.UpdateProductPrice`.
Если ввести цену `-5`, процедура вернёт ошибку через `RAISERROR`, а программа
покажет её как `SqlException`.
Бизнес-логика остаётся в приложении. Процедуры здесь только для демонстрации,
что ADO.NET умеет их вызывать.
## Проверка ввода и ошибок
Перед сохранением проверяется:
- номер введён как число;
- цена больше нуля;
- остаток не отрицательный;
- количество в заказе больше нуля;
- товар существует и его хватает;
- email не пустой.
Ошибки не скрываются: в консоль выводится сообщение, а для SQL Server показывается
код ошибки. В `Program.cs` главный цикл оборачивает каждый пункт в `try`/`catch`:
```csharp
catch (Exception ex)
{
Console.WriteLine($"[ОШИБКА]: {ex.Message}");
if (ex.InnerException != null)
Console.WriteLine($"[ДЕТАЛИ]: {ex.InnerException.Message}");
}
```
Для учебной работы такой подход удобен: видно, что произошло. В настоящем проекте
подробности писали бы в лог, а пользователю показывали короткое сообщение.
## Что показать преподавателю
1. `Data/DbConnectionFactory.cs` — подключение и строка из конфига.
2. `Data/ProductRepository.cs` — три способа выполнить команду, параметры,
`SqlDataReader`, `SqlDataAdapter`, вызов процедуры.
3. `Data/OrderRepository.cs` — транзакция: успешный заказ и откат.
4. Пункты меню 8, 10, 11, 12 — прямо во время защиты.
## Если что-то не работает
| Сообщение | Причина | Что делать |
|---|---|---|
| Сервер не найден или недоступен | SQL Server не запущен | Запустите службу SQL Server |
| Не удалось открыть соединение | неверное имя экземпляра | для Express замените `Server` на `.\\SQLEXPRESS` |
| Login failed for user | неверный способ входа | проверьте `Trusted_Connection=True` |
| Недоверенный сертификат | нет SSL-сертификата | добавьте `TrustServerCertificate=True` |
| Cannot open database "AdoNetShop" | база не создана | выполните `01_create_database.sql` |
| Invalid object name 'Products' | таблиц нет | выполните `02_create_tables.sql` |
| Could not find file appsettings.json | запустили не из той папки | запускайте из папки `AdoNetShop` |
| Требуется .NET, которого нет | нет runtime .NET 8 | установите .NET 8 SDK |
| Повторный запуск `03_insert_data.sql` даёт ошибку FK | данные вставлялись вручную | запустите скрипт заново, он чистит таблицы сам |
## Как менять строку подключения
Строка лежит в `AdoNetShop/appsettings.json`. Разбор по параметрам:
| Параметр | Значение | Зачем |
|---|---|---|
| `Server` | `localhost` | адрес сервера |
| `Database` | `AdoNetShop` | имя базы |
| `Trusted_Connection` | `True` | вход через Windows, без логина и пароля |
| `TrustServerCertificate` | `True` | доверять сертификату локального сервера |
Если нужен вход по логину и паролю:
```
Server=localhost;Database=AdoNetShop;User Id=sa;Password=Пароль;TrustServerCertificate=True;
```
Пароль в файле конфигурации — это вариант для учёбы. В настоящем проекте такие
данные хранят в переменных окружения. Важно, что в коде пароля нет.
## Замечание про кодировку
SQL-файлы сохранены в UTF-8 с меткой в начале файла, поэтому кириллица правильно
читается в SSMS, Visual Studio и в командной строке:
```bash
sqlcmd -S localhost -E -f 65001 -i database\01_create_database.sql
```
Если пересохранить файл в другой кодировке, русские названия превратятся
в нечитаемый текст.
Проект собран под `net8.0`. Если на компьютере стоит только .NET 9, 10 или новее,
приложение всё равно запустится: в `.csproj` включён `RollForward`. Если понадобится,
строку `net8.0` можно поменять на установленную версию.