Ваш 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.
| Варіант | Переваги | Недоліки | Коли використовувати |
|---|---|---|---|
| JSON/XML файли | Просто, читабельно | Повільно для великих даних, немає запитів | Налаштування, конфігурація |
| SQLite | Швидко, SQL запити, транзакції | Один файл (не підходить для мережі) | Локальні десктопні додатки |
| LocalDB | Повноцінний SQL Server | Потрібна установка, важкий | Розробка, коли потрібен SQL Server |
| PostgreSQL/MySQL | Потужні, масштабовані | Потрібен окремий сервер | Клієнт-серверні додатки |
Для типового десктопного додатку (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 — це клас, що представляє сесію з базою даних. Він містить DbSet<T> для кожної таблиці.
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}");
}
}
Шлях до бази даних:
C:\Users\{Username}\AppData\Roaming\MyApp\app.db/Users/{Username}/.config/MyApp/app.db/home/{Username}/.config/MyApp/app.dbpublic 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!;
}
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);
});
}
}
// В 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>();
}
Проблема з 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:
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 — це автоматичне додавання початкових даних при створенні бази.
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
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:
Переваги HasData:
dotnet ef database updatepublic 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);
}
}
<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>
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)
};
}
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;
}
}
Мета: Навчитися налаштовувати SQLite та EF Core у десктопному проєкті.
Завдання:
Створіть простий додаток для управління нотатками:
Note модель (Id, Title, Content, CreatedAt)AppDbContext з DbSet<Note>MainViewModel для відображення нотатокКритерії успіху:
Підказка (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);
});
}
}
Мета: Навчитися працювати з міграціями та первинним наповненням даних.
Завдання:
Розширте додаток з Рівня 1:
Category (string) — категорія нотаткиIsPinned (bool) — чи закріплена нотаткаTags (string) — теги через комуdotnet ef migrations add AddCategoryAndTagsКритерії успіху:
Підказка (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
🏗️ DbContext
🔄 Міграції
🌱 Seeding
🏭 IDbContextFactory
🔍 LINQ запити
Що далі?
Наступна стаття — Data Persistence Part 2 покаже Repository pattern, Settings management та синхронізацію з хмарою.
Dependency Injection у WPF та Avalonia
Інтеграція Microsoft.Extensions.DependencyInjection у десктопні проєкти. Реєстрація ViewModels та сервісів. Lifecycles, Composition Root, тестування.
Repository Pattern та Unit of Work
Чиста архітектура доступу до даних. IRepository<T>, GenericRepository, специфічні репозиторії. Unit of Work для координації транзакцій. User Settings через JSON.