Перейти к содержанию

Этап 2. База данных и модель предметной области

Логика модели данных

База данных разделяется на системную часть и предметную часть. Системная часть отвечает за вход в приложение, роли и журнал событий. Предметная часть описывает выбранный вариант.

Такое разделение упрощает разработку: модуль авторизации можно использовать почти без изменений, а предметные таблицы заменяются под вариант.

Системная часть

Таблица Назначение
Roles справочник ролей
Users учетные записи пользователей
LoginAttempts журнал входов

В Users пароль хранится не как текст, а как PasswordHash и PasswordSalt. Соль нужна, чтобы одинаковые пароли у разных пользователей давали разные хэши.

В SQLite системные таблицы можно создать при первом запуске приложения:

CREATE TABLE IF NOT EXISTS Roles (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Name TEXT NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS Users (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Login TEXT NOT NULL UNIQUE,
    PasswordHash TEXT NOT NULL,
    PasswordSalt TEXT NOT NULL,
    FullName TEXT NOT NULL,
    RoleId INTEGER NOT NULL,
    IsActive INTEGER NOT NULL DEFAULT 1,
    CreatedAt TEXT NOT NULL,
    FOREIGN KEY (RoleId) REFERENCES Roles(Id)
);

CREATE TABLE IF NOT EXISTS LoginAttempts (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    UserLogin TEXT NOT NULL,
    IsSuccess INTEGER NOT NULL,
    Message TEXT NOT NULL,
    CreatedAt TEXT NOT NULL
);

Почему нужен журнал входов

Журнал входов помогает увидеть, как используется система и где возникают ошибки. В него записываются не только успешные входы, но и неудачные попытки.

Поле Назначение
UserLogin какой логин вводили
IsSuccess успешна ли попытка
Message причина результата
CreatedAt дата и время

Даже если пользователь не найден, логин из формы можно записать в журнал. Это помогает увидеть повторяющиеся попытки входа.

Предметная часть

Предметная часть зависит от варианта. Для библиотеки можно использовать таблицы:

Таблица Назначение
Authors авторы книг
Books книги и инвентарные номера
Readers читатели
BookIssues выдача и возврат книг

Для автосервиса вместо этого будут Clients, Cars, RepairOrders, Services, Masters.

Фрагмент предметной части для библиотеки:

CREATE TABLE IF NOT EXISTS Authors (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    FullName TEXT NOT NULL,
    Country TEXT
);

CREATE TABLE IF NOT EXISTS Books (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Title TEXT NOT NULL,
    AuthorId INTEGER NOT NULL,
    Year INTEGER,
    InventoryNumber TEXT NOT NULL UNIQUE,
    IsAvailable INTEGER NOT NULL DEFAULT 1,
    FOREIGN KEY (AuthorId) REFERENCES Authors(Id)
);

Связи между таблицами

Связь показывает, как записи одной таблицы относятся к записям другой таблицы.

Связь Пример Как хранить
Один ко многим один автор — много книг Books.AuthorId
Один ко многим одна роль — много пользователей Users.RoleId
Многие ко многим книга может выдаваться разным читателям, читатель берет разные книги отдельная таблица BookIssues

Для операций часто создается таблица-документ. В библиотеке такой таблицей является BookIssues: она связывает книгу, читателя и даты выдачи/возврата.

Ключи и ограничения

Важные ограничения задаются в базе данных:

  • Users.Login — уникальный;
  • Books.InventoryNumber — уникальный;
  • Users.RoleId — внешний ключ на Roles;
  • Books.AuthorId — внешний ключ на Authors;
  • обязательные поля помечаются NOT NULL.

Такие ограничения защищают данные даже в том случае, если в форме забыли добавить проверку.

Типы данных

В учебном проекте удобно использовать SQLite, потому что база хранится в одном файле. При этом нужно сохранять дисциплину типов:

Данные SQLite тип Пример
идентификатор INTEGER Id
строка TEXT Login, Title
дата TEXT в формате ISO 2026-05-04T12:30:00
логическое значение INTEGER 0 или 1

Для дат лучше использовать DateTime.Now.ToString("s"), чтобы строка сохранялась в предсказуемом формате.

Строка подключения к SQLite обычно хранится в одном месте:

public static class AppConfig
{
    public const string ConnectionString = "Data Source=practice.db";
}

Подключение создается через фабрику. Тогда репозитории не дублируют строку подключения:

using Microsoft.Data.Sqlite;

public static class DbConnectionFactory
{
    public static SqliteConnection Create()
    {
        return new SqliteConnection(AppConfig.ConnectionString);
    }
}

3НФ

Третья нормальная форма помогает убрать дублирование. Например, имя автора не нужно хранить в каждой книге. Лучше хранить автора в таблице Authors, а в Books оставить только AuthorId.

Плохой вариант:

BookTitle AuthorName AuthorCountry

Хороший вариант:

Authors Books
Id, FullName, Country Id, Title, AuthorId

Инициализация базы

Для небольшого проекта достаточно класса DatabaseInitializer, который выполняет CREATE TABLE IF NOT EXISTS. Если структура таблицы меняется, нужно осознанно обновить SQL и тестовую базу.

Пример инициализации ролей и одной предметной записи:

using Microsoft.Data.Sqlite;

public static class DatabaseInitializer
{
    public static void Initialize()
    {
        using var connection = DbConnectionFactory.Create();
        connection.Open();

        Execute(connection, """
            CREATE TABLE IF NOT EXISTS Roles (
                Id INTEGER PRIMARY KEY AUTOINCREMENT,
                Name TEXT NOT NULL UNIQUE
            );

            CREATE TABLE IF NOT EXISTS Authors (
                Id INTEGER PRIMARY KEY AUTOINCREMENT,
                FullName TEXT NOT NULL,
                Country TEXT
            );
            """);

        Execute(connection, """
            INSERT OR IGNORE INTO Roles (Id, Name)
            VALUES (1, 'admin'), (2, 'operator'), (3, 'user');
            """);

        Execute(connection, """
            INSERT OR IGNORE INTO Authors (Id, FullName, Country)
            VALUES (1, 'А. С. Пушкин', 'Россия');
            """);
    }

    private static void Execute(SqliteConnection connection, string sql)
    {
        using var command = connection.CreateCommand();
        command.CommandText = sql;
        command.ExecuteNonQuery();
    }
}

В Program.cs инициализация вызывается до открытия первого окна. В Windows Forms это выглядит так:

ApplicationConfiguration.Initialize();
DatabaseInitializer.Initialize();
Application.Run(new LoginForm());

В WPF тот же вызов можно выполнить в App.xaml.cs перед созданием окна входа:

protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    DatabaseInitializer.Initialize();
    new LoginWindow().Show();
}