AdoNetShop/README.md

472 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
```
## Главные классы 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`. Если понадобится,
строку `<TargetFramework>net8.0</TargetFramework>` можно поменять на установленную версию.