Desktop UI

SQLite та EF Core у десктопних додатках

Локальне зберігання даних через SQLite. DbContext, міграції, seeding. Інтеграція з DI. IDbContextFactory для багатопоточності.

SQLite та EF Core у десктопних додатках

Ваш MVVM-додаток працює чудово... поки користувач не закриває вікно. Всі дані зникають. ObservableCollection очищається, стан втрачається, користувач починає з нуля. Потрібне збереження даних.

Можна зберігати у JSON файлах, XML, або навіть текстових файлах. Але що робити, коли даних стає багато? Коли потрібні складні запити? Коли потрібні зв'язки між таблицями? Коли потрібна транзакційність?

Рішення — SQLite + Entity Framework Core. SQLite — це embedded база даних, що зберігається в одному файлі .db. Не потрібен окремий сервер, не потрібна установка, не потрібна конфігурація. Просто файл на диску. EF Core надає зручний ORM для роботи з SQLite через C# класи замість SQL запитів.

У цій статті ми детально розберемо інтеграцію SQLite у WPF/Avalonia: від встановлення пакетів до міграцій, від DbContext до автоматичного seeding даних при першому запуску. Ви навчитесь зберігати дані локально, робити складні запити через LINQ, та інтегрувати все це з Dependency Injection.

Словник теми:SQLite — embedded реляційна база даних, що зберігається в одному файлі. Entity Framework Core (EF Core) — ORM для роботи з базами даних через C# класи. DbContext — клас для взаємодії з базою даних. DbSet — колекція для роботи з таблицею. Migration — файл з описом змін схеми бази даних. Seeding — первинне наповнення бази даних. IDbContextFactory — фабрика для створення DbContext у багатопоточних сценаріях.

Чому SQLite для десктопу?

Порівняння варіантів збереження даних

ВаріантПеревагиНедолікиКоли використовувати
JSON/XML файлиПросто, читабельноПовільно для великих даних, немає запитівНалаштування, конфігурація
SQLiteШвидко, SQL запити, транзакціїОдин файл (не підходить для мережі)Локальні десктопні додатки
LocalDBПовноцінний SQL ServerПотрібна установка, важкийРозробка, коли потрібен SQL Server
PostgreSQL/MySQLПотужні, масштабованіПотрібен окремий серверКлієнт-серверні додатки

Переваги SQLite для десктопу

  1. Embedded — база даних у одному файлі, не потрібен окремий сервер
  2. Zero-configuration — не потрібна установка або налаштування
  3. Cross-platform — працює на Windows, macOS, Linux
  4. Швидкість — оптимізовано для локального доступу
  5. Транзакції — ACID гарантії
  6. SQL запити — повноцінна реляційна база даних
  7. Малий розмір — бібліотека ~1 MB

Коли НЕ використовувати SQLite

  • Багато одночасних записів (SQLite блокує всю базу при записі)
  • Мережевий доступ (SQLite не підтримує віддалений доступ)
  • Дуже великі бази даних (> 100 GB)
  • Складні аналітичні запити (краще PostgreSQL)

Для типового десктопного додатку (CRM, inventory, note-taking, task manager) SQLite — ідеальний вибір.


Встановлення пакетів

# EF Core для SQLite
dotnet add package Microsoft.EntityFrameworkCore.Sqlite

# Інструменти для міграцій
dotnet add package Microsoft.EntityFrameworkCore.Design

# Опціонально: інструменти для CLI
dotnet tool install --global dotnet-ef

Версії: Використовуйте версію EF Core, що відповідає вашій версії .NET (наприклад, EF Core 8.0 для .NET 8).


DbContext: створення контексту

DbContext — це клас, що представляє сесію з базою даних. Він містить DbSet<T> для кожної таблиці.

Базовий DbContext

using Microsoft.EntityFrameworkCore;

public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; } = null!;
    public DbSet<Task> Tasks { get; set; } = null!;
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        // Шлях до файлу бази даних
        var dbPath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "MyApp",
            "app.db"
        );
        
        // Створюємо папку, якщо не існує
        Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
        
        optionsBuilder.UseSqlite($"Data Source={dbPath}");
    }
}

Шлях до бази даних:

  • Windows: C:\Users\{Username}\AppData\Roaming\MyApp\app.db
  • macOS: /Users/{Username}/.config/MyApp/app.db
  • Linux: /home/{Username}/.config/MyApp/app.db

Entities: моделі даних

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    
    // Navigation property
    public List<Task> Tasks { get; set; } = new();
}

public class Task
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
    public DateTime DueDate { get; set; }
    
    // Foreign key
    public int UserId { get; set; }
    public User User { get; set; } = null!;
}

Fluent API: конфігурація моделей

public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; } = null!;
    public DbSet<Task> Tasks { get; set; } = null!;
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        // Конфігурація User
        modelBuilder.Entity<User>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Name)
                .IsRequired()
                .HasMaxLength(100);
            
            entity.Property(e => e.Email)
                .IsRequired()
                .HasMaxLength(200);
            
            entity.HasIndex(e => e.Email)
                .IsUnique();
            
            // Зв'язок один-до-багатьох
            entity.HasMany(e => e.Tasks)
                .WithOne(e => e.User)
                .HasForeignKey(e => e.UserId)
                .OnDelete(DeleteBehavior.Cascade);
        });
        
        // Конфігурація Task
        modelBuilder.Entity<Task>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Title)
                .IsRequired()
                .HasMaxLength(200);
            
            entity.Property(e => e.Description)
                .HasMaxLength(1000);
        });
    }
}
Детальніше про EF Core: Якщо хочете глибше зрозуміти Fluent API, Navigation Properties, Lazy Loading, читайте розділ EF Core. Тут ми зосередимося на інтеграції у десктопні проєкти.

Інтеграція з Dependency Injection

Реєстрація DbContext

// В App.xaml.cs (WPF) або Program.cs (Avalonia)
private void ConfigureServices(IServiceCollection services)
{
    // Реєстрація DbContext як Scoped
    services.AddDbContext<AppDbContext>(options =>
    {
        var dbPath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "MyApp",
            "app.db"
        );
        
        Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
        
        options.UseSqlite($"Data Source={dbPath}");
    });
    
    // ViewModels
    services.AddTransient<MainViewModel>();
    
    // Services
    services.AddSingleton<IUserService, UserService>();
}

Чому Scoped, а не Singleton?

Проблема з Singleton:

// ❌ НЕ РОБІТЬ ТАК
services.AddSingleton<AppDbContext>();

// DbContext не є thread-safe!
// Якщо два ViewModels використовують той самий DbContext одночасно → crash

Рішення 1: Scoped (з ручним scope)

services.AddDbContext<AppDbContext>(); // За замовчуванням Scoped

// Використання
public class UserService : IUserService
{
    private readonly IServiceProvider _serviceProvider;
    
    public UserService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public async Task<List<User>> GetUsersAsync()
    {
        using var scope = _serviceProvider.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        
        return await context.Users.ToListAsync();
    }
}

Рішення 2: IDbContextFactory (рекомендовано)

// Реєстрація фабрики
services.AddDbContextFactory<AppDbContext>(options =>
{
    var dbPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
        "MyApp",
        "app.db"
    );
    
    options.UseSqlite($"Data Source={dbPath}");
});

// Використання
public class UserService : IUserService
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    
    public UserService(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }
    
    public async Task<List<User>> GetUsersAsync()
    {
        await using var context = await _contextFactory.CreateDbContextAsync();
        return await context.Users.ToListAsync();
    }
}

Переваги IDbContextFactory:

  • Thread-safe (кожен виклик створює новий DbContext)
  • Простіше у використанні (не потрібен IServiceProvider)
  • Автоматичний Dispose через await using

Міграції: управління схемою бази даних

Міграції — це файли з описом змін схеми бази даних. Вони дозволяють версіонувати схему та застосовувати зміни автоматично.

Створення першої міграції

# Створення міграції
dotnet ef migrations add InitialCreate

# Застосування міграції (створення бази даних)
dotnet ef database update

Що створюється:

Migrations/
├── 20240101000000_InitialCreate.cs          # Код міграції
├── 20240101000000_InitialCreate.Designer.cs # Метадані
└── AppDbContextModelSnapshot.cs             # Snapshot моделі

Структура міграції

public partial class InitialCreate : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "Users",
            columns: table => new
            {
                Id = table.Column<int>(nullable: false)
                    .Annotation("Sqlite:Autoincrement", true),
                Name = table.Column<string>(maxLength: 100, nullable: false),
                Email = table.Column<string>(maxLength: 200, nullable: false),
                CreatedAt = table.Column<DateTime>(nullable: false)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_Users", x => x.Id);
            });
        
        migrationBuilder.CreateIndex(
            name: "IX_Users_Email",
            table: "Users",
            column: "Email",
            unique: true);
    }
    
    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropTable(name: "Users");
    }
}

Автоматичне застосування міграцій при старті

// В App.xaml.cs OnStartup (WPF)
protected override async void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    
    _host = Host.CreateDefaultBuilder()
        .ConfigureServices((context, services) => ConfigureServices(services))
        .Build();
    
    // Застосування міграцій
    await ApplyMigrationsAsync();
    
    var mainWindow = _host.Services.GetRequiredService<MainWindow>();
    mainWindow.Show();
}

private async Task ApplyMigrationsAsync()
{
    using var scope = _host!.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
    // Застосувати всі pending міграції
    await context.Database.MigrateAsync();
}

Для Avalonia (Program.cs):

public static async Task Main(string[] args)
{
    AppHost = Host.CreateDefaultBuilder(args)
        .ConfigureServices((context, services) => ConfigureServices(services))
        .Build();
    
    // Застосування міграцій
    await ApplyMigrationsAsync();
    
    BuildAvaloniaApp()
        .StartWithClassicDesktopLifetime(args);
}

private static async Task ApplyMigrationsAsync()
{
    using var scope = AppHost!.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await context.Database.MigrateAsync();
}

Додавання нових міграцій

# Додали нове поле до User
public class User
{
    // ...
    public string? PhoneNumber { get; set; } // Нове поле
}

# Створюємо міграцію
dotnet ef migrations add AddPhoneNumberToUser

# Застосовуємо
dotnet ef database update

Seeding: первинне наповнення даних

Seeding — це автоматичне додавання початкових даних при створенні бази.

Варіант 1: HasData у OnModelCreating

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    
    // Seed користувачів
    modelBuilder.Entity<User>().HasData(
        new User
        {
            Id = 1,
            Name = "Admin",
            Email = "admin@example.com",
            CreatedAt = DateTime.UtcNow
        },
        new User
        {
            Id = 2,
            Name = "John Doe",
            Email = "john@example.com",
            CreatedAt = DateTime.UtcNow
        }
    );
    
    // Seed задач
    modelBuilder.Entity<Task>().HasData(
        new Task
        {
            Id = 1,
            Title = "Welcome Task",
            Description = "Complete your profile",
            IsCompleted = false,
            DueDate = DateTime.UtcNow.AddDays(7),
            UserId = 1
        }
    );
}

Після додавання HasData:

dotnet ef migrations add SeedInitialData
dotnet ef database update

Варіант 2: Програмний seeding при старті

private async Task SeedDataAsync()
{
    using var scope = _host!.Services.CreateScope();
    var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    
    // Перевірка, чи база порожня
    if (!await context.Users.AnyAsync())
    {
        var admin = new User
        {
            Name = "Admin",
            Email = "admin@example.com",
            CreatedAt = DateTime.UtcNow
        };
        
        context.Users.Add(admin);
        await context.SaveChangesAsync();
        
        // Додаємо задачі для admin
        context.Tasks.Add(new Task
        {
            Title = "Welcome Task",
            Description = "Complete your profile",
            IsCompleted = false,
            DueDate = DateTime.UtcNow.AddDays(7),
            UserId = admin.Id
        });
        
        await context.SaveChangesAsync();
    }
}

// Викликаємо після ApplyMigrationsAsync
protected override async void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);
    
    _host = Host.CreateDefaultBuilder()
        .ConfigureServices((context, services) => ConfigureServices(services))
        .Build();
    
    await ApplyMigrationsAsync();
    await SeedDataAsync(); // Seeding
    
    var mainWindow = _host.Services.GetRequiredService<MainWindow>();
    mainWindow.Show();
}

Переваги програмного seeding:

  • Гнучкість (можна додавати складну логіку)
  • Можна читати з файлів (JSON, CSV)
  • Можна перевіряти умови (чи база порожня)

Переваги HasData:

  • Частина міграції (версіонується разом зі схемою)
  • Автоматично застосовується при dotnet ef database update

Використання DbContext у ViewModel

Приклад: UserListViewModel

public class UserListViewModel : ViewModelBase
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;
    private ObservableCollection<User> _users = new();
    private bool _isLoading;
    
    public UserListViewModel(IDbContextFactory<AppDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
        
        LoadUsersCommand = new AsyncRelayCommand(LoadUsersAsync);
        AddUserCommand = new AsyncRelayCommand(AddUserAsync);
        DeleteUserCommand = new AsyncRelayCommand<User>(DeleteUserAsync);
    }
    
    public ObservableCollection<User> Users
    {
        get => _users;
        set => SetProperty(ref _users, value);
    }
    
    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }
    
    public IAsyncRelayCommand LoadUsersCommand { get; }
    public IAsyncRelayCommand AddUserCommand { get; }
    public IAsyncRelayCommand<User> DeleteUserCommand { get; }
    
    private async Task LoadUsersAsync()
    {
        IsLoading = true;
        
        try
        {
            await using var context = await _contextFactory.CreateDbContextAsync();
            
            var users = await context.Users
                .Include(u => u.Tasks) // Eager loading
                .OrderBy(u => u.Name)
                .ToListAsync();
            
            Users = new ObservableCollection<User>(users);
        }
        catch (Exception ex)
        {
            // Обробка помилок
            await ShowErrorAsync($"Failed to load users: {ex.Message}");
        }
        finally
        {
            IsLoading = false;
        }
    }
    
    private async Task AddUserAsync()
    {
        var newUser = new User
        {
            Name = "New User",
            Email = $"user{DateTime.Now.Ticks}@example.com",
            CreatedAt = DateTime.UtcNow
        };
        
        await using var context = await _contextFactory.CreateDbContextAsync();
        
        context.Users.Add(newUser);
        await context.SaveChangesAsync();
        
        Users.Add(newUser);
    }
    
    private async Task DeleteUserAsync(User? user)
    {
        if (user == null) return;
        
        await using var context = await _contextFactory.CreateDbContextAsync();
        
        context.Users.Remove(user);
        await context.SaveChangesAsync();
        
        Users.Remove(user);
    }
}

XAML для UserListViewModel

<Window x:Class="MyApp.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        Title="User Management" Width="800" Height="600">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="*"/>
        </Grid.RowDefinitions>
        
        <!-- Toolbar -->
        <StackPanel Orientation="Horizontal" Margin="10">
            <Button Content="Load Users" 
                    Command="{Binding LoadUsersCommand}"
                    Margin="0,0,10,0"/>
            <Button Content="Add User" 
                    Command="{Binding AddUserCommand}"/>
        </StackPanel>
        
        <!-- User List -->
        <ListBox Grid.Row="1" 
                 ItemsSource="{Binding Users}"
                 Margin="10">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <Grid Margin="5">
                        <Grid.ColumnDefinitions>
                            <ColumnDefinition Width="*"/>
                            <ColumnDefinition Width="Auto"/>
                        </Grid.ColumnDefinitions>
                        
                        <StackPanel>
                            <TextBlock Text="{Binding Name}" 
                                       FontWeight="Bold" 
                                       FontSize="16"/>
                            <TextBlock Text="{Binding Email}" 
                                       Foreground="Gray"/>
                            <TextBlock Text="{Binding Tasks.Count, StringFormat='Tasks: {0}'}" 
                                       Foreground="Gray" 
                                       FontSize="12"/>
                        </StackPanel>
                        
                        <Button Grid.Column="1" 
                                Content="Delete"
                                Command="{Binding DataContext.DeleteUserCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
                                CommandParameter="{Binding}"/>
                    </Grid>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        
        <!-- Loading Indicator -->
        <Grid Grid.RowSpan="2" 
              Background="#80000000" 
              Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibilityConverter}}">
            <StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
                <ProgressBar IsIndeterminate="True" Width="200" Height="20"/>
                <TextBlock Text="Loading..." 
                           Foreground="White" 
                           Margin="0,10,0,0"
                           HorizontalAlignment="Center"/>
            </StackPanel>
        </Grid>
    </Grid>
</Window>

Складні запити через LINQ

Фільтрація та сортування

public async Task<List<User>> SearchUsersAsync(string searchTerm)
{
    await using var context = await _contextFactory.CreateDbContextAsync();
    
    return await context.Users
        .Where(u => u.Name.Contains(searchTerm) || u.Email.Contains(searchTerm))
        .OrderBy(u => u.Name)
        .ToListAsync();
}

Пагінація

public async Task<List<User>> GetUsersPageAsync(int pageNumber, int pageSize)
{
    await using var context = await _contextFactory.CreateDbContextAsync();
    
    return await context.Users
        .OrderBy(u => u.Name)
        .Skip((pageNumber - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();
}

Агрегація

public async Task<UserStatistics> GetUserStatisticsAsync(int userId)
{
    await using var context = await _contextFactory.CreateDbContextAsync();
    
    var user = await context.Users
        .Include(u => u.Tasks)
        .FirstOrDefaultAsync(u => u.Id == userId);
    
    if (user == null)
        throw new NotFoundException($"User {userId} not found");
    
    return new UserStatistics
    {
        TotalTasks = user.Tasks.Count,
        CompletedTasks = user.Tasks.Count(t => t.IsCompleted),
        PendingTasks = user.Tasks.Count(t => !t.IsCompleted),
        OverdueTasks = user.Tasks.Count(t => !t.IsCompleted && t.DueDate < DateTime.UtcNow)
    };
}

Join та GroupBy

public async Task<List<UserTaskSummary>> GetUserTaskSummariesAsync()
{
    await using var context = await _contextFactory.CreateDbContextAsync();
    
    return await context.Users
        .Select(u => new UserTaskSummary
        {
            UserId = u.Id,
            UserName = u.Name,
            TotalTasks = u.Tasks.Count,
            CompletedTasks = u.Tasks.Count(t => t.IsCompleted)
        })
        .ToListAsync();
}

Транзакції: атомарні операції

public async Task TransferTasksAsync(int fromUserId, int toUserId)
{
    await using var context = await _contextFactory.CreateDbContextAsync();
    
    // Початок транзакції
    await using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        // Отримуємо користувачів
        var fromUser = await context.Users
            .Include(u => u.Tasks)
            .FirstOrDefaultAsync(u => u.Id == fromUserId);
        
        var toUser = await context.Users
            .FirstOrDefaultAsync(u => u.Id == toUserId);
        
        if (fromUser == null || toUser == null)
            throw new NotFoundException("User not found");
        
        // Переносимо задачі
        foreach (var task in fromUser.Tasks)
        {
            task.UserId = toUserId;
        }
        
        await context.SaveChangesAsync();
        
        // Commit транзакції
        await transaction.CommitAsync();
    }
    catch
    {
        // Rollback при помилці
        await transaction.RollbackAsync();
        throw;
    }
}

Практичні завдання

Рівень 1: Підключення SQLite до проєкту

Мета: Навчитися налаштовувати SQLite та EF Core у десктопному проєкті.

Завдання:

Створіть простий додаток для управління нотатками:

  1. Структура:
    • Note модель (Id, Title, Content, CreatedAt)
    • AppDbContext з DbSet<Note>
    • MainViewModel для відображення нотаток
  2. Функціональність:
    • Завантаження всіх нотаток при старті
    • Додавання нової нотатки
    • Видалення нотатки
    • Редагування нотатки
  3. Вимоги:
    • Використайте IDbContextFactory
    • База даних у ApplicationData
    • Автоматичне застосування міграцій при старті

Критерії успіху:

  • SQLite підключено правильно
  • Міграції створено та застосовано
  • CRUD операції працюють
  • Дані зберігаються між запусками

Підказка (Note модель):

public class Note
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? UpdatedAt { get; set; }
}

Підказка (AppDbContext):

public class AppDbContext : DbContext
{
    public DbSet<Note> Notes { get; set; } = null!;
    
    public AppDbContext(DbContextOptions<AppDbContext> options)
        : base(options)
    {
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        
        modelBuilder.Entity<Note>(entity =>
        {
            entity.HasKey(e => e.Id);
            
            entity.Property(e => e.Title)
                .IsRequired()
                .HasMaxLength(200);
            
            entity.Property(e => e.Content)
                .HasMaxLength(5000);
        });
    }
}

Рівень 2: Міграції та Seeding

Мета: Навчитися працювати з міграціями та первинним наповненням даних.

Завдання:

Розширте додаток з Рівня 1:

  1. Додайте нові поля:
    • Category (string) — категорія нотатки
    • IsPinned (bool) — чи закріплена нотатка
    • Tags (string) — теги через кому
  2. Створіть міграцію:
    • dotnet ef migrations add AddCategoryAndTags
    • Застосуйте міграцію автоматично при старті
  3. Додайте Seeding:
    • При першому запуску створіть 3 приклади нотаток
    • Різні категорії (Work, Personal, Ideas)
    • Одна закріплена нотатка

Критерії успіху:

  • Міграція створена та застосована
  • Нові поля відображаються в UI
  • Seeding працює (дані з'являються при першому запуску)
  • Повторний запуск не дублює дані

Підказка (Seeding):

private async Task SeedDataAsync()
{
    using var scope = _host!.Services.CreateScope();
    var contextFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<AppDbContext>>();
    
    await using var context = await contextFactory.CreateDbContextAsync();
    
    if (!await context.Notes.AnyAsync())
    {
        var notes = new[]
        {
            new Note
            {
                Title = "Welcome to Notes App",
                Content = "This is your first note!",
                Category = "Personal",
                IsPinned = true,
                Tags = "welcome,first",
                CreatedAt = DateTime.UtcNow
            },
            new Note
            {
                Title = "Project Ideas",
                Content = "List of project ideas...",
                Category = "Ideas",
                IsPinned = false,
                Tags = "projects,ideas",
                CreatedAt = DateTime.UtcNow
            },
            new Note
            {
                Title = "Meeting Notes",
                Content = "Notes from today's meeting...",
                Category = "Work",
                IsPinned = false,
                Tags = "work,meeting",
                CreatedAt = DateTime.UtcNow
            }
        };
        
        context.Notes.AddRange(notes);
        await context.SaveChangesAsync();
    }
}

Підсумок

SQLite + EF Core — ідеальна комбінація для локального зберігання даних у десктопних додатках. Embedded база даних без окремого сервера, потужні LINQ запити, автоматичні міграції, інтеграція з DI — все це робить розробку швидкою та приємною.

Ключові висновки:

📦 SQLite

Embedded база даних у одному файлі. Zero-configuration, cross-platform, швидка для локального доступу.

🏗️ DbContext

Клас для взаємодії з базою даних. DbSet для кожної таблиці. OnModelCreating для конфігурації.

🔄 Міграції

Версіонування схеми бази даних. Автоматичне застосування через Database.MigrateAsync(). Rollback через Down().

🌱 Seeding

Первинне наповнення даних. HasData у OnModelCreating або програмний seeding при старті.

🏭 IDbContextFactory

Thread-safe створення DbContext. Рекомендовано для десктопних додатків замість Scoped lifetime.

🔍 LINQ запити

Складні запити через C# замість SQL. Where, OrderBy, Include, Select, GroupBy — все через LINQ.

Що далі?

Наступна стаття — Data Persistence Part 2 покаже Repository pattern, Settings management та синхронізацію з хмарою.


Словник термінів

SQLite — embedded реляційна база даних, що зберігається в одному файлі без окремого сервера.Entity Framework Core (EF Core) — ORM (Object-Relational Mapping) для роботи з базами даних через C# класи.DbContext — клас, що представляє сесію з базою даних та координує операції з даними.DbSet — колекція для роботи з таблицею в базі даних (аналог таблиці).Migration — файл з описом змін схеми бази даних (додавання таблиць, полів, індексів).Seeding — первинне наповнення бази даних тестовими або початковими даними.IDbContextFactory — фабрика для створення екземплярів DbContext у багатопоточних сценаріях.Fluent API — API для конфігурації моделей через методи (альтернатива Data Annotations).Navigation Property — властивість для зв'язків між таблицями (User.Tasks, Task.User).Foreign Key — зовнішній ключ для зв'язку між таблицями.Eager Loading — завантаження пов'язаних даних одразу через Include().Lazy Loading — завантаження пов'язаних даних при першому доступі (потребує проксі).Explicit Loading — ручне завантаження пов'язаних даних через Load().LINQ (Language Integrated Query) — синтаксис для запитів до колекцій та баз даних через C#.Transaction — атомарна операція (або всі зміни застосовуються, або жодна).SaveChangesAsync() — збереження всіх змін у базі даних асинхронно.ToListAsync() — виконання запиту та отримання результату як List асинхронно.FirstOrDefaultAsync() — отримання першого елемента або null асинхронно.AnyAsync() — перевірка, чи є хоча б один елемент асинхронно.

Додаткові ресурси

📖 EF Core Docs

Офіційна документація Entity Framework Core.

🗄️ SQLite Documentation

Офіційна документація SQLite.

🔄 Migrations Guide

Детальний гайд по міграціям у EF Core.

📚 Попередня стаття: DI Integration

Повернутися до Dependency Injection.

📚 Наступна стаття: Data Persistence Part 2

Дізнатися про Repository pattern та Settings.