This guide will get you up and running with WitDatabase in minutes. We'll start with the simplest examples and progressively add more features.

Prerequisites: Make sure you've installed the appropriate WitDatabase package (see Installation.

What you'll learn:

  • Store and retrieve data with the Core key-value API
  • Execute SQL queries with ADO.NET
  • Use Entity Framework Core for object-relational mapping
  • Secure your database with encryption
  • Ensure data integrity with transactions

Your First Database

Let's start with the simplest possible database — no SQL, no configuration, just storing and retrieving data.

In-Memory Database

The fastest way to experiment with WitDatabase is using an in-memory database. Data exists only while the database is open — perfect for testing and learning.

using OutWit.Database.Core.Builder;

// Create an in-memory database
using var db = WitDatabase.CreateInMemory();

// Store a value
var key = "greeting"u8.ToArray();
var value = "Hello, WitDatabase!"u8.ToArray();
db.Put(key, value);

// Retrieve the value
var retrieved = db.Get(key);
Console.WriteLine(System.Text.Encoding.UTF8.GetString(retrieved!));
// Output: Hello, WitDatabase!

What's happening here?

  1. WitDatabase.CreateInMemory() — creates a database that lives entirely in RAM
  2. "greeting"u8.ToArray() — converts a string to a UTF-8 byte array (C# 11+ syntax)
  3. db.Put(key, value) — stores the key-value pair
  4. db.Get(key) — retrieves the value (returns null if not found)

Note: The u8 suffix creates a ReadOnlySpan<byte> from a string literal. We call .ToArray() because WitDatabase stores byte[] arrays.

File-Based Database

To persist data between application runs, use a file-based database:

using OutWit.Database.Core.Builder;

// Create a new database file (or open existing)
using var db = WitDatabase.CreateOrOpen("myapp.witdb");

// Store data
db.Put("user:1:name"u8.ToArray(), "Alice"u8.ToArray());
db.Put("user:1:email"u8.ToArray(), "alice@example.com"u8.ToArray());
db.Put("user:2:name"u8.ToArray(), "Bob"u8.ToArray());
db.Put("user:2:email"u8.ToArray(), "bob@example.com"u8.ToArray());

Console.WriteLine("Data saved to myapp.witdb");

Static factory methods:

Method Description
WitDatabase.Create(path) Create new database (fails if exists)
WitDatabase.Open(path) Open existing database (fails if not exists)
WitDatabase.CreateOrOpen(path) Create new or open existing (most common)
WitDatabase.CreateInMemory() Create in-memory database

Basic Key-Value Operations

WitDatabase at its core is a key-value store. Here are the fundamental operations:

using OutWit.Database.Core.Builder;
using System.Text;

using var db = WitDatabase.CreateOrOpen("demo.witdb");

// Helper to convert strings to bytes
byte[] ToBytes(string s) => Encoding.UTF8.GetBytes(s);
string FromBytes(byte[] b) => Encoding.UTF8.GetString(b);

// ═══════════════════════════════════════════════════════════
// PUT — Store a value
// ═══════════════════════════════════════════════════════════
db.Put(ToBytes("name"), ToBytes("Alice"));
db.Put(ToBytes("age"), ToBytes("30"));
db.Put(ToBytes("city"), ToBytes("New York"));

// ═══════════════════════════════════════════════════════════
// GET — Retrieve a value
// ═══════════════════════════════════════════════════════════
var name = db.Get(ToBytes("name"));
if (name != null)
{
    Console.WriteLine(
Loading...
quot;Name: {FromBytes(name)}"); // Name: Alice } // Check if key exists var unknown = db.Get(ToBytes("unknown_key")); Console.WriteLine(
Loading...
quot;Unknown key exists: {unknown != null}"); // false // ═══════════════════════════════════════════════════════════ // DELETE — Remove a value // ═══════════════════════════════════════════════════════════ db.Delete(ToBytes("city")); var city = db.Get(ToBytes("city")); Console.WriteLine(
Loading...
quot;City after delete: {city != null}"); // false // ═══════════════════════════════════════════════════════════ // UPDATE — Put overwrites existing values // ═══════════════════════════════════════════════════════════ db.Put(ToBytes("age"), ToBytes("31")); // Happy birthday! var newAge = db.Get(ToBytes("age")); Console.WriteLine(
Loading...
quot;New age: {FromBytes(newAge!)}"); // 31 // ═══════════════════════════════════════════════════════════ // EXISTS — Check without retrieving // ═══════════════════════════════════════════════════════════ bool exists = db.Exists(ToBytes("name")); Console.WriteLine(
Loading...
quot;Name exists: {exists}"); // true

Scanning Keys

You can iterate over a range of keys using Scan:

using var db = WitDatabase.CreateOrOpen("users.witdb");

// Store some users with prefixed keys
db.Put("user:001:name"u8.ToArray(), "Alice"u8.ToArray());
db.Put("user:001:email"u8.ToArray(), "alice@example.com"u8.ToArray());
db.Put("user:002:name"u8.ToArray(), "Bob"u8.ToArray());
db.Put("user:002:email"u8.ToArray(), "bob@example.com"u8.ToArray());
db.Put("user:003:name"u8.ToArray(), "Charlie"u8.ToArray());
db.Put("config:theme"u8.ToArray(), "dark"u8.ToArray());

// Scan all keys starting with "user:"
var startKey = "user:"u8.ToArray();
var endKey = "user:\xff"u8.ToArray();  // \xff ensures we get all "user:*" keys

Console.WriteLine("All user keys:");
foreach (var (key, value) in db.Scan(startKey, endKey))
{
    var keyStr = System.Text.Encoding.UTF8.GetString(key);
    var valueStr = System.Text.Encoding.UTF8.GetString(value);
    Console.WriteLine(
Loading...
quot; {keyStr} = {valueStr}"); } // Output: // user:001:email = alice@example.com // user:001:name = Alice // user:002:email = bob@example.com // user:002:name = Bob // user:003:name = Charlie

Key design tips:

  • Use prefixes to group related data: user:, order:, config:
  • Use separators for hierarchy: user:123:profile:name
  • Keys are sorted lexicographically — design keys with this in mind
  • Keep keys short — they're stored with every value

Async Operations

All operations have async versions for non-blocking I/O:

using var db = WitDatabase.CreateOrOpen("async-demo.witdb");

// Async put
await db.PutAsync("key1"u8.ToArray(), "value1"u8.ToArray());

// Async get
var value = await db.GetAsync("key1"u8.ToArray());

// Async delete
await db.DeleteAsync("key1"u8.ToArray());

// Async scan
await foreach (var (key, val) in db.ScanAsync(startKey, endKey))
{
    // Process key-value pair
}

Working with Complex Data

For storing objects, serialize them to bytes. Here's an example with JSON:

using System.Text.Json;
using OutWit.Database.Core.Builder;

using var db = WitDatabase.CreateOrOpen("objects.witdb");

// Define a class
public record User(int Id, string Name, string Email, DateTime CreatedAt);

// Serialize to JSON bytes
var user = new User(1, "Alice", "alice@example.com", DateTime.UtcNow);
var key = 
Loading...
quot;user:{user.Id}"u8.ToArray(); var value = JsonSerializer.SerializeToUtf8Bytes(user); db.Put(key, value); // Deserialize back var retrieved = db.Get(key); var loadedUser = JsonSerializer.Deserialize<User>(retrieved!); Console.WriteLine(
Loading...
quot;Loaded: {loadedUser!.Name} ({loadedUser.Email})");

Tip: For better performance, consider using MessagePack or MemoryPack instead of JSON.

Complete Example: Simple Note Store

Let's build a simple note storage application:

using System.Text;
using System.Text.Json;
using OutWit.Database.Core.Builder;

// ═══════════════════════════════════════════════════════════
// Simple Note Store using WitDatabase Core API
// ═══════════════════════════════════════════════════════════

using var db = WitDatabase.CreateOrOpen("notes.witdb");

// Data model
record Note(string Id, string Title, string Content, DateTime CreatedAt, DateTime? UpdatedAt);

// Helper functions
string NewId() => Guid.NewGuid().ToString("N")[..8];
byte[] NoteKey(string id) => Encoding.UTF8.GetBytes(
Loading...
quot;note:{id}"); byte[] ToJson(Note note) => JsonSerializer.SerializeToUtf8Bytes(note); Note FromJson(byte[] data) => JsonSerializer.Deserialize<Note>(data)!; // ───────────────────────────────────────────────────────────── // Create some notes // ───────────────────────────────────────────────────────────── var note1 = new Note(NewId(), "Shopping List", "Milk, Bread, Eggs", DateTime.UtcNow, null); var note2 = new Note(NewId(), "Meeting Notes", "Discuss Q3 roadmap", DateTime.UtcNow, null); var note3 = new Note(NewId(), "Ideas", "Build a note-taking app!", DateTime.UtcNow, null); db.Put(NoteKey(note1.Id), ToJson(note1)); db.Put(NoteKey(note2.Id), ToJson(note2)); db.Put(NoteKey(note3.Id), ToJson(note3)); Console.WriteLine(
Loading...
quot;Created 3 notes"); // ───────────────────────────────────────────────────────────── // List all notes // ───────────────────────────────────────────────────────────── Console.WriteLine("\nAll notes:"); var start = "note:"u8.ToArray(); var end = "note:\xff"u8.ToArray(); foreach (var (key, value) in db.Scan(start, end)) { var note = FromJson(value); Console.WriteLine(
Loading...
quot; [{note.Id}] {note.Title}"); } // ───────────────────────────────────────────────────────────── // Read a specific note // ───────────────────────────────────────────────────────────── var data = db.Get(NoteKey(note1.Id)); if (data != null) { var loaded = FromJson(data); Console.WriteLine(
Loading...
quot;\nNote details:"); Console.WriteLine(
Loading...
quot; Title: {loaded.Title}"); Console.WriteLine(
Loading...
quot; Content: {loaded.Content}"); Console.WriteLine(
Loading...
quot; Created: {loaded.CreatedAt}"); } // ───────────────────────────────────────────────────────────── // Update a note // ───────────────────────────────────────────────────────────── var updated = note1 with { Content = "Milk, Bread, Eggs, Cheese", UpdatedAt = DateTime.UtcNow }; db.Put(NoteKey(updated.Id), ToJson(updated)); Console.WriteLine(
Loading...
quot;\nUpdated note: {updated.Title}"); // ───────────────────────────────────────────────────────────── // Delete a note // ───────────────────────────────────────────────────────────── db.Delete(NoteKey(note3.Id)); Console.WriteLine(
Loading...
quot;Deleted note: {note3.Title}"); // ───────────────────────────────────────────────────────────── // Count remaining notes // ───────────────────────────────────────────────────────────── int count = 0; foreach (var _ in db.Scan(start, end)) count++; Console.WriteLine(
Loading...
quot;\nTotal notes: {count}");

Output:

Created 3 notes

All notes:
  [a1b2c3d4] Shopping List
  [e5f6g7h8] Meeting Notes
  [i9j0k1l2] Ideas

Note details:
  Title: Shopping List
  Content: Milk, Bread, Eggs
  Created: 2025-01-20T12:00:00Z

Updated note: Shopping List
Deleted note: Ideas

Total notes: 2

When to Use Core API vs SQL

Use Core API when... Use SQL (ADO.NET/EF Core) when...
You need maximum performance You need complex queries
Simple key-value patterns suffice You need JOINs, aggregations
You're building a custom data layer You want familiar SQL syntax
Data model is hierarchical (keys with prefixes) Data model is relational
You want full control over serialization You want automatic mapping

Most applications benefit from the SQL layer. The Core API is useful for:

  • Caching layers
  • Session storage
  • Simple configuration stores
  • Custom high-performance scenarios

Your First SQL Database

Now let's add SQL capabilities. The ADO.NET provider gives you familiar patterns — DbConnection, DbCommand, DbDataReader — with full SQL support.

Package required: OutWit.Database.AdoNet

Creating a Connection

Every SQL operation starts with a connection:

using OutWit.Database.AdoNet;

// Create and open a connection
using var connection = new WitDbConnection("Data Source=myapp.witdb");
connection.Open();

// Now you can execute SQL commands
Console.WriteLine(
Loading...
quot;Connected! State: {connection.State}");

Connection string options:

// File database
"Data Source=myapp.witdb"

// In-memory (for testing)
"Data Source=:memory:"

// With encryption
"Data Source=secure.witdb;Encryption=aes-gcm;Password=MySecret123"

// Read-only mode
"Data Source=myapp.witdb;Mode=ReadOnly"

Tip: Always use using to ensure the connection is properly disposed.


Creating Tables

Use CREATE TABLE to define your schema:

using OutWit.Database.AdoNet;

using var connection = new WitDbConnection("Data Source=tasks.witdb");
connection.Open();

using var cmd = connection.CreateCommand();

// Create a Tasks table
cmd.CommandText = """
    CREATE TABLE IF NOT EXISTS Tasks (
        Id INTEGER PRIMARY KEY AUTOINCREMENT,
        Title VARCHAR(200) NOT NULL,
        Description TEXT,
        Priority INTEGER DEFAULT 0,
        IsComplete BOOLEAN DEFAULT FALSE,
        DueDate DATETIME,
        CreatedAt DATETIME DEFAULT NOW()
    )
    """;
cmd.ExecuteNonQuery();

Console.WriteLine("Table created successfully!");

Supported data types:

SQL Type .NET Type Notes
INTEGER int, long 64-bit signed integer
REAL double 64-bit floating point
TEXT string UTF-8 string
BLOB byte[] Binary data
BOOLEAN bool Stored as 0/1
DATETIME DateTime ISO 8601 format
DECIMAL(p,s) decimal Exact numeric
VARCHAR(n) string Limited length string
GUID Guid 16-byte UUID

Common constraints:

-- Primary key with auto-increment
Id INTEGER PRIMARY KEY AUTOINCREMENT

-- Not null
Title VARCHAR(200) NOT NULL

-- Default value
Priority INTEGER DEFAULT 0
CreatedAt DATETIME DEFAULT NOW()

-- Unique
Email VARCHAR(255) UNIQUE

-- Check constraint
CHECK (Priority >= 0 AND Priority <= 5)

-- Foreign key
FOREIGN KEY (UserId) REFERENCES Users(Id) ON DELETE CASCADE

Inserting Data

Simple Insert

using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT INTO Tasks (Title, Priority) VALUES ('Buy groceries', 1)";
int rowsAffected = cmd.ExecuteNonQuery();
Console.WriteLine(
Loading...
quot;Inserted {rowsAffected} row(s)");

Always use parameters to prevent SQL injection and handle special characters:

using var cmd = connection.CreateCommand();
cmd.CommandText = """
    INSERT INTO Tasks (Title, Description, Priority, DueDate) 
    VALUES (@title, @description, @priority, @dueDate)
    """;

cmd.Parameters.Add(new WitDbParameter("@title", "Finish report"));
cmd.Parameters.Add(new WitDbParameter("@description", "Q4 financial summary"));
cmd.Parameters.Add(new WitDbParameter("@priority", 2));
cmd.Parameters.Add(new WitDbParameter("@dueDate", DateTime.Now.AddDays(7)));

cmd.ExecuteNonQuery();

Getting the Inserted ID

// Method 1: Use RETURNING clause (recommended)
cmd.CommandText = """
    INSERT INTO Tasks (Title, Priority) 
    VALUES (@title, @priority)
    RETURNING Id
    """;
cmd.Parameters.Add(new WitDbParameter("@title", "New task"));
cmd.Parameters.Add(new WitDbParameter("@priority", 1));

var newId = cmd.ExecuteScalar();
Console.WriteLine(
Loading...
quot;Inserted task with Id: {newId}"); // Method 2: Use LAST_INSERT_ROWID() cmd.CommandText = "INSERT INTO Tasks (Title) VALUES ('Another task')"; cmd.ExecuteNonQuery(); cmd.CommandText = "SELECT LAST_INSERT_ROWID()"; var lastId = cmd.ExecuteScalar(); Console.WriteLine(
Loading...
quot;Last inserted Id: {lastId}");

Bulk Insert

For inserting many rows efficiently, use a transaction:

using var transaction = connection.BeginTransaction();

using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = "INSERT INTO Tasks (Title, Priority) VALUES (@title, @priority)";

var titleParam = new WitDbParameter("@title", "");
var priorityParam = new WitDbParameter("@priority", 0);
cmd.Parameters.Add(titleParam);
cmd.Parameters.Add(priorityParam);

// Prepare the command for repeated execution
cmd.Prepare();

// Insert 1000 tasks
for (int i = 1; i <= 1000; i++)
{
    titleParam.Value = 
Loading...
quot;Task #{i}"; priorityParam.Value = i % 5; cmd.ExecuteNonQuery(); } transaction.Commit(); Console.WriteLine("Inserted 1000 tasks");

Querying Data

ExecuteReader — Multiple Rows

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Id, Title, Priority, IsComplete FROM Tasks ORDER BY Priority DESC";

using var reader = cmd.ExecuteReader();

Console.WriteLine("Tasks:");
Console.WriteLine("─".PadRight(60, '─'));

while (reader.Read())
{
    var id = reader.GetInt32(0);           // By index
    var title = reader["Title"];            // By name
    var priority = reader.GetInt32(2);
    var isComplete = reader.GetBoolean(3);
    
    var status = isComplete ? "✓" : "○";
    Console.WriteLine(
Loading...
quot; {status} [{priority}] {title} (Id: {id})"); }

ExecuteScalar — Single Value

cmd.CommandText = "SELECT COUNT(*) FROM Tasks WHERE IsComplete = FALSE";
var incompleteCount = Convert.ToInt32(cmd.ExecuteScalar());
Console.WriteLine(
Loading...
quot;Incomplete tasks: {incompleteCount}"); cmd.CommandText = "SELECT MAX(Priority) FROM Tasks"; var maxPriority = cmd.ExecuteScalar(); Console.WriteLine(
Loading...
quot;Highest priority: {maxPriority}");

Filtering with WHERE

cmd.CommandText = """
    SELECT Id, Title, DueDate 
    FROM Tasks 
    WHERE Priority >= @minPriority 
      AND IsComplete = FALSE
      AND DueDate <= @deadline
    ORDER BY DueDate
    """;
cmd.Parameters.Clear();
cmd.Parameters.Add(new WitDbParameter("@minPriority", 2));
cmd.Parameters.Add(new WitDbParameter("@deadline", DateTime.Now.AddDays(7)));

using var reader = cmd.ExecuteReader();
while (reader.Read())
{
    Console.WriteLine(
Loading...
quot; {reader["Title"]} - Due: {reader["DueDate"]}"); }

Checking for NULL Values

while (reader.Read())
{
    var title = reader.GetString(reader.GetOrdinal("Title"));
    
    // Check for NULL before reading
    var descriptionOrdinal = reader.GetOrdinal("Description");
    var description = reader.IsDBNull(descriptionOrdinal) 
        ? "(no description)" 
        : reader.GetString(descriptionOrdinal);
    
    Console.WriteLine(
Loading...
quot; {title}: {description}"); }

Mapping to Objects

A common pattern is mapping query results to C# objects:

public record Task(
    int Id, 
    string Title, 
    string? Description, 
    int Priority, 
    bool IsComplete,
    DateTime? DueDate,
    DateTime CreatedAt
);

public static List<Task> GetAllTasks(WitDbConnection connection)
{
    var tasks = new List<Task>();
    
    using var cmd = connection.CreateCommand();
    cmd.CommandText = "SELECT * FROM Tasks ORDER BY CreatedAt DESC";
    
    using var reader = cmd.ExecuteReader();
    while (reader.Read())
    {
        tasks.Add(new Task(
            Id: reader.GetInt32(reader.GetOrdinal("Id")),
            Title: reader.GetString(reader.GetOrdinal("Title")),
            Description: reader.IsDBNull(reader.GetOrdinal("Description")) 
                ? null 
                : reader.GetString(reader.GetOrdinal("Description")),
            Priority: reader.GetInt32(reader.GetOrdinal("Priority")),
            IsComplete: reader.GetBoolean(reader.GetOrdinal("IsComplete")),
            DueDate: reader.IsDBNull(reader.GetOrdinal("DueDate")) 
                ? null 
                : reader.GetDateTime(reader.GetOrdinal("DueDate")),
            CreatedAt: reader.GetDateTime(reader.GetOrdinal("CreatedAt"))
        ));
    }
    
    return tasks;
}

// Usage
var allTasks = GetAllTasks(connection);
foreach (var task in allTasks)
{
    Console.WriteLine(
Loading...
quot; [{task.Priority}] {task.Title}"); }

Updating Data

Update Specific Rows

using var cmd = connection.CreateCommand();
cmd.CommandText = """
    UPDATE Tasks 
    SET IsComplete = TRUE 
    WHERE Id = @id
    """;
cmd.Parameters.Add(new WitDbParameter("@id", 5));

int rowsAffected = cmd.ExecuteNonQuery();
Console.WriteLine(
Loading...
quot;Updated {rowsAffected} row(s)");

Update with RETURNING

Get the updated values in a single query:

cmd.CommandText = """
    UPDATE Tasks 
    SET Priority = Priority + 1 
    WHERE Id = @id
    RETURNING Id, Title, Priority
    """;
cmd.Parameters.Clear();
cmd.Parameters.Add(new WitDbParameter("@id", 3));

using var reader = cmd.ExecuteReader();
if (reader.Read())
{
    Console.WriteLine(
Loading...
quot;Updated: {reader["Title"]} - New priority: {reader["Priority"]}"); }

Bulk Update

// Mark all overdue tasks as high priority
cmd.CommandText = """
    UPDATE Tasks 
    SET Priority = 5 
    WHERE DueDate < @now AND IsComplete = FALSE
    """;
cmd.Parameters.Clear();
cmd.Parameters.Add(new WitDbParameter("@now", DateTime.Now));

int updated = cmd.ExecuteNonQuery();
Console.WriteLine(
Loading...
quot;Marked {updated} overdue task(s) as high priority");

Deleting Data

Delete Specific Rows

using var cmd = connection.CreateCommand();
cmd.CommandText = "DELETE FROM Tasks WHERE Id = @id";
cmd.Parameters.Add(new WitDbParameter("@id", 10));

int deleted = cmd.ExecuteNonQuery();
Console.WriteLine(
Loading...
quot;Deleted {deleted} row(s)");

Delete with Condition

// Delete all completed tasks older than 30 days
cmd.CommandText = """
    DELETE FROM Tasks 
    WHERE IsComplete = TRUE 
      AND CreatedAt < @cutoffDate
    """;
cmd.Parameters.Clear();
cmd.Parameters.Add(new WitDbParameter("@cutoffDate", DateTime.Now.AddDays(-30)));

int cleaned = cmd.ExecuteNonQuery();
Console.WriteLine(
Loading...
quot;Cleaned up {cleaned} old completed task(s)");

Delete with RETURNING

cmd.CommandText = """
    DELETE FROM Tasks 
    WHERE Id = @id
    RETURNING Id, Title
    """;
cmd.Parameters.Clear();
cmd.Parameters.Add(new WitDbParameter("@id", 7));

using var reader = cmd.ExecuteReader();
if (reader.Read())
{
    Console.WriteLine(
Loading...
quot;Deleted task: {reader["Title"]}"); } else { Console.WriteLine("Task not found"); }

Checking Changes

Use CHANGES() to see how many rows were affected by the last statement:

cmd.CommandText = "UPDATE Tasks SET Priority = 3 WHERE Priority > 3";
cmd.ExecuteNonQuery();

cmd.CommandText = "SELECT CHANGES()";
var changes = Convert.ToInt32(cmd.ExecuteScalar());
Console.WriteLine(
Loading...
quot;Rows affected: {changes}");

Complete Example: Task Manager

Here's a complete, working example that demonstrates all CRUD operations:

using OutWit.Database.AdoNet;

// ═══════════════════════════════════════════════════════════════════
// Task Manager - Complete ADO.NET Example
// ═══════════════════════════════════════════════════════════════════

using var connection = new WitDbConnection("Data Source=taskmanager.witdb");
connection.Open();

// ───────────────────────────────────────────────────────────────────
// 1. Create the schema
// ───────────────────────────────────────────────────────────────────
ExecuteNonQuery(connection, """
    CREATE TABLE IF NOT EXISTS Tasks (
        Id INTEGER PRIMARY KEY AUTOINCREMENT,
        Title VARCHAR(200) NOT NULL,
        Description TEXT,
        Priority INTEGER DEFAULT 0 CHECK (Priority >= 0 AND Priority <= 5),
        IsComplete BOOLEAN DEFAULT FALSE,
        DueDate DATETIME,
        CreatedAt DATETIME DEFAULT NOW()
    )
    """);
Console.WriteLine("✓ Schema ready\n");

// ───────────────────────────────────────────────────────────────────
// 2. Insert sample tasks
// ───────────────────────────────────────────────────────────────────
var tasks = new[]
{
    ("Write documentation", "Complete the Quick Start guide", 3, DateTime.Now.AddDays(2)),
    ("Review pull requests", null as string, 2, DateTime.Now.AddDays(1)),
    ("Fix bug #1234", "NullReferenceException in OrderService", 5, DateTime.Now),
    ("Update dependencies", "Upgrade to .NET 10", 1, DateTime.Now.AddDays(14)),
    ("Team meeting", "Weekly sync", 2, DateTime.Now.AddDays(3)),
};

foreach (var (title, description, priority, dueDate) in tasks)
{
    InsertTask(connection, title, description, priority, dueDate);
}
Console.WriteLine(
Loading...
quot;✓ Inserted {tasks.Length} tasks\n"); // ─────────────────────────────────────────────────────────────────── // 3. List all tasks // ─────────────────────────────────────────────────────────────────── Console.WriteLine("All Tasks:"); Console.WriteLine("─".PadRight(70, '─')); PrintTasks(connection, "SELECT * FROM Tasks ORDER BY Priority DESC, DueDate"); // ─────────────────────────────────────────────────────────────────── // 4. Find high-priority tasks // ─────────────────────────────────────────────────────────────────── Console.WriteLine("\nHigh Priority Tasks (≥3):"); Console.WriteLine("─".PadRight(70, '─')); PrintTasks(connection, "SELECT * FROM Tasks WHERE Priority >= 3 ORDER BY Priority DESC"); // ─────────────────────────────────────────────────────────────────── // 5. Complete a task // ─────────────────────────────────────────────────────────────────── var completedTitle = CompleteTask(connection, 3); // Complete task #3 Console.WriteLine(
Loading...
quot;\n✓ Completed: \"{completedTitle}\"\n"); // ─────────────────────────────────────────────────────────────────── // 6. Show statistics // ─────────────────────────────────────────────────────────────────── ShowStatistics(connection); // ─────────────────────────────────────────────────────────────────── // 7. Delete completed tasks // ─────────────────────────────────────────────────────────────────── int deleted = DeleteCompletedTasks(connection); Console.WriteLine(
Loading...
quot;\n✓ Deleted {deleted} completed task(s)"); // ─────────────────────────────────────────────────────────────────── // 8. Final task list // ─────────────────────────────────────────────────────────────────── Console.WriteLine("\nRemaining Tasks:"); Console.WriteLine("─".PadRight(70, '─')); PrintTasks(connection, "SELECT * FROM Tasks ORDER BY DueDate"); // ═══════════════════════════════════════════════════════════════════ // Helper Methods // ═══════════════════════════════════════════════════════════════════ static void ExecuteNonQuery(WitDbConnection conn, string sql) { using var cmd = conn.CreateCommand(); cmd.CommandText = sql; cmd.ExecuteNonQuery(); } static int InsertTask(WitDbConnection conn, string title, string? description, int priority, DateTime dueDate) { using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO Tasks (Title, Description, Priority, DueDate) VALUES (@title, @desc, @priority, @due) RETURNING Id """; cmd.Parameters.Add(new WitDbParameter("@title", title)); cmd.Parameters.Add(new WitDbParameter("@desc", description ?? (object)DBNull.Value)); cmd.Parameters.Add(new WitDbParameter("@priority", priority)); cmd.Parameters.Add(new WitDbParameter("@due", dueDate)); return Convert.ToInt32(cmd.ExecuteScalar()); } static void PrintTasks(WitDbConnection conn, string sql) { using var cmd = conn.CreateCommand(); cmd.CommandText = sql; using var reader = cmd.ExecuteReader(); while (reader.Read()) { var id = reader.GetInt32(reader.GetOrdinal("Id")); var title = reader.GetString(reader.GetOrdinal("Title")); var priority = reader.GetInt32(reader.GetOrdinal("Priority")); var isComplete = reader.GetBoolean(reader.GetOrdinal("IsComplete")); var dueDate = reader.GetDateTime(reader.GetOrdinal("DueDate")); var status = isComplete ? "✓" : "○"; var priorityStars = new string('★', priority) + new string('☆', 5 - priority); var dueDays = (dueDate.Date - DateTime.Today).Days; var dueText = dueDays switch { < 0 =>
Loading...
quot;OVERDUE by {-dueDays}d", 0 => "TODAY", 1 => "Tomorrow", _ =>
Loading...
quot;in {dueDays}d" }; Console.WriteLine(
Loading...
quot; {status} #{id,-3} [{priorityStars}] {title,-30} ({dueText})"); } } static string CompleteTask(WitDbConnection conn, int taskId) { using var cmd = conn.CreateCommand(); cmd.CommandText = """ UPDATE Tasks SET IsComplete = TRUE WHERE Id = @id RETURNING Title """; cmd.Parameters.Add(new WitDbParameter("@id", taskId)); return cmd.ExecuteScalar()?.ToString() ?? "(not found)"; } static void ShowStatistics(WitDbConnection conn) { using var cmd = conn.CreateCommand(); Console.WriteLine("\nStatistics:"); Console.WriteLine("─".PadRight(30, '─')); cmd.CommandText = "SELECT COUNT(*) FROM Tasks"; Console.WriteLine(
Loading...
quot; Total tasks: {cmd.ExecuteScalar()}"); cmd.CommandText = "SELECT COUNT(*) FROM Tasks WHERE IsComplete = TRUE"; Console.WriteLine(
Loading...
quot; Completed: {cmd.ExecuteScalar()}"); cmd.CommandText = "SELECT COUNT(*) FROM Tasks WHERE IsComplete = FALSE"; Console.WriteLine(
Loading...
quot; Pending: {cmd.ExecuteScalar()}"); cmd.CommandText = "SELECT COUNT(*) FROM Tasks WHERE DueDate < @now AND IsComplete = FALSE"; cmd.Parameters.Add(new WitDbParameter("@now", DateTime.Now)); Console.WriteLine(
Loading...
quot; Overdue: {cmd.ExecuteScalar()}"); cmd.Parameters.Clear(); cmd.CommandText = "SELECT AVG(Priority) FROM Tasks WHERE IsComplete = FALSE"; var avgPriority = cmd.ExecuteScalar(); Console.WriteLine(
Loading...
quot; Avg priority: {avgPriority:F1}"); } static int DeleteCompletedTasks(WitDbConnection conn) { using var cmd = conn.CreateCommand(); cmd.CommandText = "DELETE FROM Tasks WHERE IsComplete = TRUE"; return cmd.ExecuteNonQuery(); }

Sample output:

✓ Schema ready

✓ Inserted 5 tasks

All Tasks:
──────────────────────────────────────────────────────────────────────
  ○ #3  [★★★★★] Fix bug #1234                     (TODAY)
  ○ #1  [★★★☆☆] Write documentation               (in 2d)
  ○ #2  [★★☆☆☆] Review pull requests              (Tomorrow)
  ○ #5  [★★☆☆☆] Team meeting                      (in 3d)
  ○ #4  [★☆☆☆☆] Update dependencies               (in 14d)

High Priority Tasks (≥3):
──────────────────────────────────────────────────────────────────────
  ○ #3  [★★★★★] Fix bug #1234                     (TODAY)
  ○ #1  [★★★☆☆] Write documentation               (in 2d)

✓ Completed: "Fix bug #1234"

Statistics:
──────────────────────────────────
  Total tasks:      5
  Completed:        1
  Pending:          4
  Overdue:          0
  Avg priority:     2.0

✓ Deleted 1 completed task(s)

Remaining Tasks:
──────────────────────────────────────────────────────────────────────
  ○ #2  [★★☆☆☆] Review pull requests              (Tomorrow)
  ○ #1  [★★★☆☆] Write documentation               (in 2d)
  ○ #5  [★★☆☆☆] Team meeting                      (in 3d)
  ○ #4  [★☆☆☆☆] Update dependencies               (in 14d)

Async Operations

All ADO.NET operations have async versions:

using var connection = new WitDbConnection("Data Source=app.witdb");
await connection.OpenAsync();

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Tasks";

await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
    // Process row
}

// Or for non-query
cmd.CommandText = "UPDATE Tasks SET Priority = 5 WHERE Id = 1";
await cmd.ExecuteNonQueryAsync();

// Or for scalar
cmd.CommandText = "SELECT COUNT(*) FROM Tasks";
var count = await cmd.ExecuteScalarAsync();

ADO.NET Summary

Operation Method Returns
Create/Update/Delete ExecuteNonQuery() Rows affected (int)
Single value ExecuteScalar() First column of first row (object?)
Multiple rows ExecuteReader() WitDbDataReader
Get inserted ID RETURNING Id or LAST_INSERT_ROWID() ID value

Best practices:

  1. Always use parameters — prevents SQL injection, handles escaping
  2. Use using — ensures proper disposal of connections, commands, readers
  3. Use transactions for batch operations — better performance and atomicity
  4. Use RETURNING — get affected data in a single round-trip
  5. Use Prepare() — for repeated execution of the same command

Your First EF Core Application

Entity Framework Core provides a higher-level abstraction over the database. Instead of writing SQL, you work with C# objects and LINQ queries. EF Core handles the mapping, change tracking, and SQL generation automatically.

Package required: OutWit.Database.EntityFramework

Why EF Core?

ADO.NET EF Core
Write SQL manually Write LINQ, SQL generated
Map results to objects manually Automatic object mapping
Track changes manually Automatic change tracking
Write migrations manually Automatic migrations
More control, more code Less code, more productivity

Use EF Core when:

  • You want rapid development
  • Your data model maps well to objects
  • You need migrations support
  • You want to share code with other EF Core providers (SQL Server, PostgreSQL)

Use ADO.NET when:

  • You need maximum performance
  • You have complex SQL that LINQ can't express
  • You want full control over queries

Defining Your Model

Start by defining your entity classes — these represent your database tables:

// A simple Todo item
public class Todo
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public bool IsComplete { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public DateTime? CompletedAt { get; set; }
}

EF Core conventions:

  • Property named Id or <ClassName>Id → Primary Key
  • stringVARCHAR(MAX) / TEXT
  • int, longINTEGER
  • boolBOOLEAN
  • DateTimeDATETIME
  • Nullable types (string?, DateTime?) → NULL allowed

Creating a DbContext

The DbContext is your gateway to the database. It manages entity objects and their relationship to the database:

using Microsoft.EntityFrameworkCore;
using OutWit.Database.EntityFramework;

public class AppDbContext : DbContext
{
    // DbSet properties represent tables
    public DbSet<Todo> Todos => Set<Todo>();

    // Configure the database connection
    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseWitDb("Data Source=todos.witdb");
    }
}

With Dependency Injection (ASP.NET Core)

// In Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseWitDb("Data Source=todos.witdb"));

// In your DbContext - remove OnConfiguring, use constructor
public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) 
        : base(options) { }

    public DbSet<Todo> Todos => Set<Todo>();
}

Creating the Database

using var context = new AppDbContext();

// Create database and tables if they don't exist
context.Database.EnsureCreated();

Console.WriteLine("Database ready!");

Note: EnsureCreated() creates the database based on your model. For production apps, use Migrations instead (covered in Part 4).


Basic CRUD Operations

Create — Adding Entities

using var context = new AppDbContext();
context.Database.EnsureCreated();

// Add a single entity
var todo = new Todo
{
    Title = "Learn WitDatabase",
    Description = "Complete the Quick Start guide"
};

context.Todos.Add(todo);
context.SaveChanges();

Console.WriteLine(
Loading...
quot;Created todo with Id: {todo.Id}");

Adding multiple entities:

var todos = new[]
{
    new Todo { Title = "Buy groceries" },
    new Todo { Title = "Call mom" },
    new Todo { Title = "Finish report", Description = "Q4 summary" }
};

context.Todos.AddRange(todos);
context.SaveChanges();

Console.WriteLine(
Loading...
quot;Created {todos.Length} todos");

Read — Querying Entities

Get all entities:

var allTodos = context.Todos.ToList();

foreach (var todo in allTodos)
{
    Console.WriteLine(
Loading...
quot; [{(todo.IsComplete ? "x" : " ")}] {todo.Title}"); }

Find by primary key:

var todo = context.Todos.Find(1);

if (todo != null)
{
    Console.WriteLine(
Loading...
quot;Found: {todo.Title}"); }

Query with conditions:

// Incomplete todos
var incomplete = context.Todos
    .Where(t => !t.IsComplete)
    .ToList();

// Single item (throws if not found or multiple)
var specific = context.Todos
    .Single(t => t.Title == "Learn WitDatabase");

// First matching (or null)
var first = context.Todos
    .FirstOrDefault(t => t.Title.Contains("report"));

// Check existence
bool hasIncomplete = context.Todos.Any(t => !t.IsComplete);

// Count
int incompleteCount = context.Todos.Count(t => !t.IsComplete);

Update — Modifying Entities

// Find the entity
var todo = context.Todos.Find(1);

if (todo != null)
{
    // Modify properties
    todo.IsComplete = true;
    todo.CompletedAt = DateTime.UtcNow;
    
    // Save changes
    context.SaveChanges();
    
    Console.WriteLine(
Loading...
quot;Completed: {todo.Title}"); }

Bulk update pattern:

// Mark all old incomplete todos as complete
var oldTodos = context.Todos
    .Where(t => !t.IsComplete && t.CreatedAt < DateTime.UtcNow.AddDays(-30))
    .ToList();

foreach (var todo in oldTodos)
{
    todo.IsComplete = true;
    todo.CompletedAt = DateTime.UtcNow;
}

context.SaveChanges();
Console.WriteLine(
Loading...
quot;Auto-completed {oldTodos.Count} old todos");

Delete — Removing Entities

// Find and remove
var todo = context.Todos.Find(5);

if (todo != null)
{
    context.Todos.Remove(todo);
    context.SaveChanges();
    
    Console.WriteLine(
Loading...
quot;Deleted: {todo.Title}"); }

Remove multiple:

// Remove all completed todos
var completed = context.Todos
    .Where(t => t.IsComplete)
    .ToList();

context.Todos.RemoveRange(completed);
context.SaveChanges();

Console.WriteLine(
Loading...
quot;Removed {completed.Count} completed todos");

LINQ Queries

EF Core translates LINQ to SQL. Here are common patterns:

Filtering and Sorting

var results = context.Todos
    .Where(t => !t.IsComplete)                    // WHERE IsComplete = FALSE
    .Where(t => t.Title.Contains("report"))       // AND Title LIKE '%report%'
    .OrderByDescending(t => t.CreatedAt)          // ORDER BY CreatedAt DESC
    .ThenBy(t => t.Title)                         // THEN BY Title
    .ToList();

Selecting Specific Columns

// Anonymous type projection
var titles = context.Todos
    .Where(t => !t.IsComplete)
    .Select(t => new { t.Id, t.Title })
    .ToList();

foreach (var item in titles)
{
    Console.WriteLine(
Loading...
quot; #{item.Id}: {item.Title}"); }

Pagination

int page = 2;
int pageSize = 10;

var pagedTodos = context.Todos
    .OrderBy(t => t.Id)
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToList();

Aggregations

// Count
int total = context.Todos.Count();
int incomplete = context.Todos.Count(t => !t.IsComplete);

// Any / All
bool hasAny = context.Todos.Any();
bool allComplete = context.Todos.All(t => t.IsComplete);

// Min / Max
var oldest = context.Todos.Min(t => t.CreatedAt);
var newest = context.Todos.Max(t => t.CreatedAt);

Grouping

var stats = context.Todos
    .GroupBy(t => t.IsComplete)
    .Select(g => new 
    { 
        IsComplete = g.Key, 
        Count = g.Count() 
    })
    .ToList();

foreach (var stat in stats)
{
    var label = stat.IsComplete ? "Complete" : "Incomplete";
    Console.WriteLine(
Loading...
quot; {label}: {stat.Count}"); }

Working with Relationships

Defining Relationships

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    
    // Navigation property - one user has many todos
    public List<Todo> Todos { get; set; } = new();
}

public class Todo
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsComplete { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    
    // Foreign key
    public int UserId { get; set; }
    
    // Navigation property - each todo belongs to one user
    public User User { get; set; } = null!;
}

public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Todo> Todos => Set<Todo>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseWitDb("Data Source=app.witdb");
}
using var context = new AppDbContext();
context.Database.EnsureCreated();

// Create a user with todos
var user = new User
{
    Name = "Alice",
    Email = "alice@example.com",
    Todos = new List<Todo>
    {
        new() { Title = "Buy milk" },
        new() { Title = "Call dentist" },
        new() { Title = "Finish project" }
    }
};

context.Users.Add(user);
context.SaveChanges();

Console.WriteLine(
Loading...
quot;Created user {user.Name} with {user.Todos.Count} todos");

Querying with Include

By default, navigation properties are not loaded. Use Include to load them:

// Load user with their todos
var userWithTodos = context.Users
    .Include(u => u.Todos)
    .FirstOrDefault(u => u.Name == "Alice");

if (userWithTodos != null)
{
    Console.WriteLine(
Loading...
quot;User: {userWithTodos.Name}"); foreach (var todo in userWithTodos.Todos) { Console.WriteLine(
Loading...
quot; - {todo.Title}"); } }
// Find todos for a specific user
var aliceTodos = context.Todos
    .Include(t => t.User)
    .Where(t => t.User.Name == "Alice")
    .ToList();

// Or using the navigation property
var alice = context.Users
    .Include(u => u.Todos)
    .First(u => u.Name == "Alice");

foreach (var todo in alice.Todos.Where(t => !t.IsComplete))
{
    Console.WriteLine(
Loading...
quot; {todo.Title}"); }

Model Configuration

Using Data Annotations

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

public class Product
{
    public int Id { get; set; }
    
    [Required]
    [MaxLength(100)]
    public string Name { get; set; } = string.Empty;
    
    [MaxLength(500)]
    public string? Description { get; set; }
    
    [Column(TypeName = "decimal(18,2)")]
    public decimal Price { get; set; }
    
    [Required]
    public string SKU { get; set; } = string.Empty;
}

Using Fluent API

public class AppDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseWitDb("Data Source=shop.witdb");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(entity =>
        {
            // Primary key
            entity.HasKey(e => e.Id);
            
            // Required field with max length
            entity.Property(e => e.Name)
                .IsRequired()
                .HasMaxLength(100);
            
            // Optional field
            entity.Property(e => e.Description)
                .HasMaxLength(500);
            
            // Decimal precision
            entity.Property(e => e.Price)
                .HasColumnType("decimal(18,2)");
            
            // Unique index
            entity.HasIndex(e => e.SKU)
                .IsUnique();
            
            // Composite index
            entity.HasIndex(e => new { e.Name, e.Price });
        });
    }
}

Complete Example: Todo Application

Here's a complete, working example with users and todos:

using Microsoft.EntityFrameworkCore;
using OutWit.Database.EntityFramework;

// ═══════════════════════════════════════════════════════════════════
// Todo Application - Complete EF Core Example
// ═══════════════════════════════════════════════════════════════════

// Run the application
await RunTodoApp();

async Task RunTodoApp()
{
    await using var context = new TodoDbContext();
    await context.Database.EnsureCreatedAsync();
    
    Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
    Console.WriteLine("║              Todo Application with EF Core                   ║");
    Console.WriteLine("╚══════════════════════════════════════════════════════════════╝\n");

    // ─────────────────────────────────────────────────────────────────
    // 1. Create users
    // ─────────────────────────────────────────────────────────────────
    if (!await context.Users.AnyAsync())
    {
        var users = new[]
        {
            new User { Name = "Alice", Email = "alice@example.com" },
            new User { Name = "Bob", Email = "bob@example.com" }
        };
        
        context.Users.AddRange(users);
        await context.SaveChangesAsync();
        Console.WriteLine(
Loading...
quot;✓ Created {users.Length} users\n"); } // ───────────────────────────────────────────────────────────────── // 2. Create todos for Alice // ───────────────────────────────────────────────────────────────── var alice = await context.Users.FirstAsync(u => u.Name == "Alice"); if (!await context.Todos.AnyAsync(t => t.UserId == alice.Id)) { var aliceTodos = new[] { new Todo { Title = "Review PR #123", Priority = Priority.High, UserId = alice.Id }, new Todo { Title = "Write documentation", Priority = Priority.Medium, UserId = alice.Id }, new Todo { Title = "Update dependencies", Priority = Priority.Low, UserId = alice.Id }, new Todo { Title = "Fix login bug", Priority = Priority.Critical, UserId = alice.Id }, new Todo { Title = "Team meeting", Priority = Priority.Medium, UserId = alice.Id }, }; context.Todos.AddRange(aliceTodos); await context.SaveChangesAsync(); Console.WriteLine(
Loading...
quot;✓ Created {aliceTodos.Length} todos for Alice\n"); } // ───────────────────────────────────────────────────────────────── // 3. List all users with todo counts // ───────────────────────────────────────────────────────────────── Console.WriteLine("Users:"); Console.WriteLine("─".PadRight(50, '─')); var usersWithCounts = await context.Users .Select(u => new { u.Name, u.Email, TodoCount = u.Todos.Count, IncompleteCount = u.Todos.Count(t => !t.IsComplete) }) .ToListAsync(); foreach (var user in usersWithCounts) { Console.WriteLine(
Loading...
quot; {user.Name} ({user.Email})"); Console.WriteLine(
Loading...
quot; Todos: {user.TodoCount} total, {user.IncompleteCount} incomplete\n"); } // ───────────────────────────────────────────────────────────────── // 4. List Alice's todos by priority // ───────────────────────────────────────────────────────────────── Console.WriteLine("Alice's Todos (by priority):"); Console.WriteLine("─".PadRight(50, '─')); var aliceTodosList = await context.Todos .Where(t => t.User.Name == "Alice") .OrderByDescending(t => t.Priority) .ToListAsync(); foreach (var todo in aliceTodosList) { var status = todo.IsComplete ? "✓" : "○"; var priority = todo.Priority.ToString().PadRight(8); Console.WriteLine(
Loading...
quot; {status} [{priority}] {todo.Title}"); } // ───────────────────────────────────────────────────────────────── // 5. Complete the critical bug // ───────────────────────────────────────────────────────────────── var criticalBug = await context.Todos .FirstOrDefaultAsync(t => t.Title.Contains("bug") && t.Priority == Priority.Critical); if (criticalBug != null && !criticalBug.IsComplete) { criticalBug.IsComplete = true; criticalBug.CompletedAt = DateTime.UtcNow; await context.SaveChangesAsync(); Console.WriteLine(
Loading...
quot;\n✓ Completed: {criticalBug.Title}"); } // ───────────────────────────────────────────────────────────────── // 6. Show statistics // ───────────────────────────────────────────────────────────────── Console.WriteLine("\nStatistics:"); Console.WriteLine("─".PadRight(50, '─')); var stats = await context.Todos .GroupBy(t => t.Priority) .Select(g => new { Priority = g.Key, Count = g.Count(), Completed = g.Count(t => t.IsComplete) }) .OrderByDescending(s => s.Priority) .ToListAsync(); foreach (var stat in stats) { var pct = stat.Count > 0 ? (stat.Completed * 100 / stat.Count) : 0; Console.WriteLine(
Loading...
quot; {stat.Priority,-10}: {stat.Completed}/{stat.Count} complete ({pct}%)"); } // ───────────────────────────────────────────────────────────────── // 7. Find overdue or high-priority incomplete todos // ───────────────────────────────────────────────────────────────── Console.WriteLine("\n⚠ Action Required:"); Console.WriteLine("─".PadRight(50, '─')); var actionRequired = await context.Todos .Include(t => t.User) .Where(t => !t.IsComplete && t.Priority >= Priority.High) .OrderByDescending(t => t.Priority) .ToListAsync(); if (actionRequired.Any()) { foreach (var todo in actionRequired) { Console.WriteLine(
Loading...
quot; [{todo.Priority}] {todo.Title} (assigned to {todo.User.Name})"); } } else { Console.WriteLine(" No high-priority items pending!"); } Console.WriteLine("\n✓ Done!"); } // ═══════════════════════════════════════════════════════════════════ // Entity Classes // ═══════════════════════════════════════════════════════════════════ public enum Priority { Low = 0, Medium = 1, High = 2, Critical = 3 } 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; } = DateTime.UtcNow; public List<Todo> Todos { get; set; } = new(); } public class Todo { public int Id { get; set; } public string Title { get; set; } = string.Empty; public string? Description { get; set; } public Priority Priority { get; set; } = Priority.Medium; public bool IsComplete { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? CompletedAt { get; set; } public int UserId { get; set; } public User User { get; set; } = null!; } // ═══════════════════════════════════════════════════════════════════ // DbContext // ═══════════════════════════════════════════════════════════════════ public class TodoDbContext : DbContext { public DbSet<User> Users => Set<User>(); public DbSet<Todo> Todos => Set<Todo>(); protected override void OnConfiguring(DbContextOptionsBuilder options) { options.UseWitDb("Data Source=todo-app.witdb"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { // User configuration modelBuilder.Entity<User>(entity => { entity.HasIndex(e => e.Email).IsUnique(); entity.Property(e => e.Name).HasMaxLength(100); entity.Property(e => e.Email).HasMaxLength(255); }); // Todo configuration modelBuilder.Entity<Todo>(entity => { entity.Property(e => e.Title).HasMaxLength(200); entity.Property(e => e.Description).HasMaxLength(1000); // Relationship entity.HasOne(t => t.User) .WithMany(u => u.Todos) .HasForeignKey(t => t.UserId) .OnDelete(DeleteBehavior.Cascade); }); } }

Sample output:

╔══════════════════════════════════════════════════════════════╗
║              Todo Application with EF Core                   ║
╚══════════════════════════════════════════════════════════════╝

✓ Created 2 users

✓ Created 5 todos for Alice

Users:
──────────────────────────────────────────────────
  Alice (alice@example.com)
    Todos: 5 total, 5 incomplete

  Bob (bob@example.com)
    Todos: 0 total, 0 incomplete

Alice's Todos (by priority):
──────────────────────────────────────────────────
  ○ [Critical] Fix login bug
  ○ [High    ] Review PR #123
  ○ [Medium  ] Write documentation
  ○ [Medium  ] Team meeting
  ○ [Low     ] Update dependencies

✓ Completed: Fix login bug

Statistics:
──────────────────────────────────────────────────
  Critical  : 1/1 complete (100%)
  High      : 0/1 complete (0%)
  Medium    : 0/2 complete (0%)
  Low       : 0/1 complete (0%)

⚠ Action Required:
──────────────────────────────────────────────────
  [High] Review PR #123 (assigned to Alice)

✓ Done!

Async Best Practices

EF Core is designed for async operations. Use them for better scalability:

// Prefer async methods
var todos = await context.Todos.ToListAsync();
var todo = await context.Todos.FindAsync(id);
var count = await context.Todos.CountAsync();
var exists = await context.Todos.AnyAsync(t => t.Id == id);
var first = await context.Todos.FirstOrDefaultAsync(t => t.Title == "Test");

await context.SaveChangesAsync();
await context.Database.EnsureCreatedAsync();

Async enumeration for large datasets:

await foreach (var todo in context.Todos.AsAsyncEnumerable())
{
    // Process one at a time, doesn't load all into memory
    Console.WriteLine(todo.Title);
}

EF Core Summary

Task Code
Create entity context.Todos.Add(todo); context.SaveChanges();
Read all context.Todos.ToList()
Find by ID context.Todos.Find(id)
Query with filter context.Todos.Where(t => ...).ToList()
Update todo.Title = "New"; context.SaveChanges();
Delete context.Todos.Remove(todo); context.SaveChanges();
Include related context.Todos.Include(t => t.User).ToList()

EF Core advantages with WitDatabase:

  • Same patterns as SQL Server, PostgreSQL, MySQL
  • Automatic change tracking
  • LINQ queries translated to SQL
  • Navigation properties and relationships
  • Migrations support (see Part 4)
  • Easy to switch providers for testing/production

Adding Encryption

WitDatabase provides built-in encryption to protect your data at rest. All data is encrypted before writing to disk and decrypted when reading — completely transparent to your application code.

Why Encrypt?

Scenario Risk Without Encryption
Desktop application User's files can be opened by anyone with file access
Mobile app Device theft exposes all data
Shared hosting Other tenants might access your files
Backup storage Backups contain readable data
Compliance (GDPR, HIPAA) May require encryption at rest

WitDatabase encryption provides:

  • AES-256-GCM — industry standard, hardware-accelerated
  • ChaCha20-Poly1305 — fast in software, ideal for WASM
  • Page-level encryption — each 4KB page encrypted independently
  • Authenticated encryption — detects tampering

Quick Start: Password-Based Encryption

The simplest way to enable encryption — just add password to your connection string:

ADO.NET

using OutWit.Database.AdoNet;

// Just add Encryption and Password parameters
var connectionString = "Data Source=secure.witdb;Encryption=aes-gcm;Password=MySecretPassword123!";

using var connection = new WitDbConnection(connectionString);
connection.Open();

// Use exactly like an unencrypted database
using var cmd = connection.CreateCommand();
cmd.CommandText = "CREATE TABLE IF NOT EXISTS Secrets (Id INTEGER PRIMARY KEY, Data TEXT)";
cmd.ExecuteNonQuery();

cmd.CommandText = "INSERT INTO Secrets (Data) VALUES (@data)";
cmd.Parameters.Add(new WitDbParameter("@data", "This is encrypted!"));
cmd.ExecuteNonQuery();

Console.WriteLine("Data saved with AES-256 encryption!");

Entity Framework Core

using Microsoft.EntityFrameworkCore;
using OutWit.Database.EntityFramework;

public class SecureDbContext : DbContext
{
    public DbSet<Secret> Secrets => Set<Secret>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseWitDb("Data Source=secure.witdb;Encryption=aes-gcm;Password=MySecretPassword123!");
    }
}

public class Secret
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Value { get; set; } = string.Empty;
}

// Usage
using var context = new SecureDbContext();
context.Database.EnsureCreated();

context.Secrets.Add(new Secret { Name = "API_KEY", Value = "sk-1234567890" });
context.SaveChangesAsync();

Core API

using OutWit.Database.Core.Builder;

// Using builder pattern
using var db = new WitDatabaseBuilder()
    .WithFilePath("secure.witdb")
    .WithBTree()
    .WithEncryption("MySecretPassword123!")
    .Build();

db.Put("secret-key"u8.ToArray(), "secret-value"u8.ToArray());

Encryption Algorithms

WitDatabase supports two encryption algorithms:

AES-256-GCM (Default)

Best for: Desktop, server, mobile (with AES-NI hardware support)

// Connection string
"Data Source=app.witdb;Encryption=aes-gcm;Password=secret"

// Builder
.WithEncryption("password")  // AES-GCM is default

Characteristics:

  • Hardware-accelerated on modern CPUs (AES-NI instruction set)
  • 256-bit key, 128-bit authentication tag
  • Industry standard, NIST approved
  • ~3-5 GB/s on modern hardware with AES-NI

ChaCha20-Poly1305

Best for: Blazor WebAssembly, ARM devices without AES-NI

// Requires: dotnet add package OutWit.Database.Core.BouncyCastle

// Connection string
"Data Source=app.witdb;Encryption=chacha20;Password=secret"

// Builder
using OutWit.Database.Core.BouncyCastle;

var db = new WitDatabaseBuilder()
    .WithFilePath("secure.witdb")
    .WithBTree()
    .WithBouncyCastleEncryption("password")
    .Build();

Characteristics:

  • Pure software implementation (no hardware requirements)
  • 256-bit key, 128-bit authentication tag
  • Faster than AES in software (no AES-NI)
  • Designed by Daniel J. Bernstein
  • Used by TLS 1.3, WireGuard, Signal

Which to Choose?

Environment Recommended Why
Windows/Linux/macOS (x64) AES-GCM Hardware acceleration
Blazor WebAssembly ChaCha20 No AES-NI in browser
ARM without AES extensions ChaCha20 Faster in software
iOS/Android (modern) AES-GCM Most have hardware AES
Maximum compatibility ChaCha20 Works everywhere

Password Best Practices

Strong Passwords

// ❌ Bad - too short, predictable
"password"
"123456"
"myapp"

// ✅ Good - long, mixed characters
"Hk9$mP2!xL7@nQ4&"
"correct-horse-battery-staple"
"MyApp-2024-SecureDB-#1"

Requirements for strong passwords:

  • At least 12 characters (16+ recommended)
  • Mix of uppercase, lowercase, numbers, symbols
  • Not a dictionary word
  • Unique per database

User-Provided Passwords

For user-facing encryption (e.g., password-protected documents):

public class SecureDocumentService
{
    public void CreateDocument(string filePath, string userPassword)
    {
        // Validate password strength
        if (userPassword.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters");
        
        using var db = new WitDatabaseBuilder()
            .WithFilePath(filePath)
            .WithBTree()
            .WithEncryption(userPassword)
            .Build();
        
        // Initialize document structure...
    }
    
    public WitDatabase OpenDocument(string filePath, string userPassword)
    {
        try
        {
            var db = new WitDatabaseBuilder()
                .WithFilePath(filePath)
                .WithBTree()
                .WithEncryption(userPassword)
                .Build();
            
            // Verify we can read (will fail with wrong password)
            db.Get("_meta"u8.ToArray());
            
            return db;
        }
        catch (CryptographicException)
        {
            throw new UnauthorizedAccessException("Invalid password");
        }
    }
}

Application Secrets

For application-level encryption, don't hardcode passwords:

// ❌ Bad - hardcoded password
var conn = new WitDbConnection("Data Source=app.witdb;Encryption=aes-gcm;Password=hardcoded123");

// ✅ Good - from environment variable
var password = Environment.GetEnvironmentVariable("MYAPP_DB_PASSWORD")
    ?? throw new InvalidOperationException("Database password not configured");
var conn = new WitDbConnection(
Loading...
quot;Data Source=app.witdb;Encryption=aes-gcm;Password={password}"); // ✅ Good - from configuration var password = Configuration["Database:Password"]; var conn = new WitDbConnection(
Loading...
quot;Data Source=app.witdb;Encryption=aes-gcm;Password={password}"); // ✅ Good - from secure storage (Windows DPAPI, macOS Keychain, etc.) var password = SecureStorage.GetPassword("myapp-db");

User-Based Key Derivation

For multi-user scenarios, derive unique keys per user:

// Each user gets a unique encryption key derived from username + password
var connectionString = 
Loading...
quot;Data Source=user_{userId}.witdb;Encryption=aes-gcm;User={username};Password={password}"; using var connection = new WitDbConnection(connectionString); connection.Open();

How it works:

  1. Salt is derived from username (deterministic)
  2. Key is derived using PBKDF2 with user's password and salt
  3. Same username + password always produces same key
  4. Different users can't decrypt each other's data
// Builder API equivalent
var db = new WitDatabaseBuilder()
    .WithFilePath(
Loading...
quot;user_{userId}.witdb") .WithBTree() .WithEncryption(username, password) // User + password .Build();

Fast Encryption for WASM

Browser environments (Blazor WebAssembly) are slower. Use optimized key derivation:

// Standard - 100,000 PBKDF2 iterations (secure but slow in WASM)
"Data Source=app.witdb;Encryption=aes-gcm;Password=secret"

// Fast - 10,000 iterations (faster, still secure for most use cases)
"Data Source=app.witdb;Encryption=aes-gcm;Password=secret;Fast Encryption=true"

// Builder API
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()
    .WithEncryptionFast("password")  // Optimized for WASM
    .Build();

Note: Reduced iterations are still secure against casual attacks. For high-security scenarios in WASM, consider using ChaCha20 which is faster overall.


Encryption with Raw Keys

For advanced scenarios, you can provide the encryption key directly:

// Generate a 256-bit key (32 bytes)
var key = new byte[32];
RandomNumberGenerator.Fill(key);

// Store this key securely (e.g., Azure Key Vault, AWS KMS)
Console.WriteLine(
Loading...
quot;Key (base64): {Convert.ToBase64String(key)}"); // Use the key var db = new WitDatabaseBuilder() .WithFilePath("secure.witdb") .WithBTree() .WithAesEncryption(key) .Build();

Use cases:

  • Integration with key management services (Azure Key Vault, AWS KMS, HashiCorp Vault)
  • Hardware security modules (HSM)
  • Key rotation scenarios

Complete Example: Encrypted Password Manager

using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using OutWit.Database.EntityFramework;

// ═══════════════════════════════════════════════════════════════════
// Simple Password Manager with Encrypted Storage
// ═══════════════════════════════════════════════════════════════════

Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
Console.WriteLine("║              Encrypted Password Manager                      ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════╝\n");

// Get master password from user
Console.Write("Enter master password: ");
var masterPassword = Console.ReadLine() ?? throw new InvalidOperationException("Password required");

// Validate password strength
if (masterPassword.Length < 8)
{
    Console.WriteLine("Error: Password must be at least 8 characters");
    return;
}

try
{
    await using var context = new PasswordVaultContext(masterPassword);
    await context.Database.EnsureCreatedAsync();
    
    Console.WriteLine("✓ Vault unlocked successfully!\n");

    // Add some passwords
    if (!await context.Credentials.AnyAsync())
    {
        var credentials = new[]
        {
            new Credential 
            { 
                Site = "github.com", 
                Username = "myuser", 
                Password = GeneratePassword(16),
                Notes = "Main development account"
            },
            new Credential 
            { 
                Site = "aws.amazon.com", 
                Username = "admin@company.com", 
                Password = GeneratePassword(20),
                Notes = "Production AWS account"
            },
            new Credential 
            { 
                Site = "database.example.com", 
                Username = "dbadmin", 
                Password = GeneratePassword(24),
                Notes = "PostgreSQL admin"
            }
        };
        
        context.Credentials.AddRange(credentials);
        await context.SaveChangesAsync();
        Console.WriteLine(
Loading...
quot;✓ Added {credentials.Length} sample credentials\n"); } // List all credentials Console.WriteLine("Stored Credentials:"); Console.WriteLine("─".PadRight(70, '─')); var allCredentials = await context.Credentials .OrderBy(c => c.Site) .ToListAsync(); foreach (var cred in allCredentials) { Console.WriteLine(
Loading...
quot; Site: {cred.Site}"); Console.WriteLine(
Loading...
quot; Username: {cred.Username}"); Console.WriteLine(
Loading...
quot; Password: {MaskPassword(cred.Password)}"); if (!string.IsNullOrEmpty(cred.Notes)) Console.WriteLine(
Loading...
quot; Notes: {cred.Notes}"); Console.WriteLine(); } // Show a specific password Console.Write("Show password for site (or press Enter to skip): "); var searchSite = Console.ReadLine(); if (!string.IsNullOrWhiteSpace(searchSite)) { var found = await context.Credentials .FirstOrDefaultAsync(c => c.Site.Contains(searchSite, StringComparison.OrdinalIgnoreCase)); if (found != null) { Console.WriteLine(
Loading...
quot;\n✓ Password for {found.Site}: {found.Password}"); } else { Console.WriteLine(
Loading...
quot;\n✗ No credential found for '{searchSite}'"); } } Console.WriteLine("\n✓ Vault locked. Data remains encrypted on disk."); } catch (Exception ex) when (ex.Message.Contains("decrypt") || ex.Message.Contains("authentication")) { Console.WriteLine("\n✗ Error: Invalid master password or corrupted vault."); } // ═══════════════════════════════════════════════════════════════════ // Helper Methods // ═══════════════════════════════════════════════════════════════════ static string GeneratePassword(int length) { const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"; var password = new char[length]; var random = RandomNumberGenerator.Create(); var bytes = new byte[length]; random.GetBytes(bytes); for (int i = 0; i < length; i++) password[i] = chars[bytes[i] % chars.Length]; return new string(password); } static string MaskPassword(string password) { if (password.Length <= 4) return new string('*', password.Length); return password[..2] + new string('*', password.Length - 4) + password[^2..]; } // ═══════════════════════════════════════════════════════════════════ // Entity Classes // ═══════════════════════════════════════════════════════════════════ public class Credential { public int Id { get; set; } public string Site { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string? Notes { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? ModifiedAt { get; set; } } // ═══════════════════════════════════════════════════════════════════ // DbContext with Encryption // ═══════════════════════════════════════════════════════════════════ public class PasswordVaultContext : DbContext { private readonly string _masterPassword; public PasswordVaultContext(string masterPassword) { _masterPassword = masterPassword; } public DbSet<Credential> Credentials => Set<Credential>(); protected override void OnConfiguring(DbContextOptionsBuilder options) { // AES-256-GCM encryption with master password options.UseWitDb(
Loading...
quot;Data Source=vault.witdb;Encryption=aes-gcm;Password={_masterPassword}"); } protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Credential>(entity => { entity.HasIndex(e => e.Site); entity.Property(e => e.Site).HasMaxLength(255); entity.Property(e => e.Username).HasMaxLength(255); entity.Property(e => e.Password).HasMaxLength(500); entity.Property(e => e.Notes).HasMaxLength(2000); }); } }

Sample output:

╔══════════════════════════════════════════════════════════════╗
║              Encrypted Password Manager                      ║
╚══════════════════════════════════════════════════════════════╝

Enter master password: MySecureVault123!
✓ Vault unlocked successfully!

✓ Added 3 sample credentials

Stored Credentials:
──────────────────────────────────────────────────────────────────────
  Site:     aws.amazon.com
  Username: admin@company.com
  Password: Kx**************9#
  Notes:    Production AWS account

  Site:     database.example.com
  Username: dbadmin
  Password: 7Q**********************&m
  Notes:    PostgreSQL admin

  Site:     github.com
  Username: myuser
  Password: Np************@3
  Notes:    Main development account

Show password for site (or press Enter to skip): github
✓ Password for github.com: NpK$8mL2xQ#4nJ@3

✓ Vault locked. Data remains encrypted on disk.

Verifying Encryption

To verify your database is actually encrypted, try opening it without the password:

// Create encrypted database
var db1 = new WitDatabaseBuilder()
    .WithFilePath("encrypted.witdb")
    .WithBTree()
    .WithEncryption("correct-password")
    .Build();

db1.Put("key"u8.ToArray(), "secret value"u8.ToArray());
db1.Dispose();

// Try to open without password - should fail
try
{
    var db2 = new WitDatabaseBuilder()
        .WithFilePath("encrypted.witdb")
        .WithBTree()
        // No encryption!
        .Build();
    
    var value = db2.Get("key"u8.ToArray());
    Console.WriteLine("ERROR: Database opened without password!");
}
catch (Exception ex)
{
    Console.WriteLine(
Loading...
quot;✓ Correctly failed: {ex.Message}"); } // Try with wrong password - should fail try { var db3 = new WitDatabaseBuilder() .WithFilePath("encrypted.witdb") .WithBTree() .WithEncryption("wrong-password") .Build(); var value = db3.Get("key"u8.ToArray()); Console.WriteLine("ERROR: Database opened with wrong password!"); } catch (Exception ex) { Console.WriteLine(
Loading...
quot;✓ Correctly failed: {ex.Message}"); }

Encryption Summary

Scenario Configuration
Basic encryption Encryption=aes-gcm;Password=secret
Blazor WASM Encryption=chacha20;Password=secret
Fast (reduced iterations) Encryption=aes-gcm;Password=secret;Fast Encryption=true
Per-user encryption Encryption=aes-gcm;User=username;Password=userpass
Raw key (256-bit) .WithAesEncryption(keyBytes)

Security checklist:

  • ✅ Use strong passwords (12+ characters)
  • ✅ Don't hardcode passwords in source code
  • ✅ Use environment variables or secure configuration
  • ✅ Use ChaCha20 for Blazor WebAssembly
  • ✅ Consider per-user key derivation for multi-user apps

Using Transactions

Transactions ensure that a group of operations either all succeed or all fail together. This is essential for maintaining data integrity — you never end up with partially completed operations.

Why Transactions?

Consider transferring money between accounts:

// ❌ Without transaction - DANGEROUS!
account1.Balance -= 100;  // Debit
SaveChanges();
// What if the app crashes here?
account2.Balance += 100;  // Credit
SaveChanges();

If the application crashes after the debit but before the credit, $100 vanishes! With transactions:

// ✅ With transaction - SAFE
BeginTransaction();
account1.Balance -= 100;  // Debit
account2.Balance += 100;  // Credit
Commit();  // Both happen, or neither happens

ACID properties guaranteed by transactions:

Property Meaning
Atomicity All operations succeed or all fail
Consistency Database moves from one valid state to another
Isolation Concurrent transactions don't interfere
Durability Committed data survives crashes

ADO.NET Transactions

Basic Transaction

using OutWit.Database.AdoNet;

using var connection = new WitDbConnection("Data Source=bank.witdb");
connection.Open();

// Create accounts table
using var setupCmd = connection.CreateCommand();
setupCmd.CommandText = """
    CREATE TABLE IF NOT EXISTS Accounts (
        Id INTEGER PRIMARY KEY,
        Name VARCHAR(100) NOT NULL,
        Balance DECIMAL(18,2) NOT NULL DEFAULT 0
    )
    """;
setupCmd.ExecuteNonQuery();

// Begin transaction
using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;  // Associate command with transaction
    
    // Debit from account 1
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id";
    cmd.Parameters.Add(new WitDbParameter("@amount", 100.00m));
    cmd.Parameters.Add(new WitDbParameter("@id", 1));
    cmd.ExecuteNonQuery();
    
    // Credit to account 2
    cmd.Parameters.Clear();
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @id";
    cmd.Parameters.Add(new WitDbParameter("@amount", 100.00m));
    cmd.Parameters.Add(new WitDbParameter("@id", 2));
    cmd.ExecuteNonQuery();
    
    // All succeeded - commit
    transaction.Commit();
    Console.WriteLine("Transfer complete!");
}
catch (Exception ex)
{
    // Something failed - rollback all changes
    transaction.Rollback();
    Console.WriteLine(
Loading...
quot;Transfer failed: {ex.Message}"); }

Transaction with Validation

Check business rules before committing:

using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    // Check source account has sufficient funds
    cmd.CommandText = "SELECT Balance FROM Accounts WHERE Id = @id";
    cmd.Parameters.Add(new WitDbParameter("@id", sourceAccountId));
    var balance = Convert.ToDecimal(cmd.ExecuteScalar());
    
    if (balance < transferAmount)
    {
        throw new InvalidOperationException("Insufficient funds");
    }
    
    // Perform transfer
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id";
    cmd.Parameters.Clear();
    cmd.Parameters.Add(new WitDbParameter("@amount", transferAmount));
    cmd.Parameters.Add(new WitDbParameter("@id", sourceAccountId));
    cmd.ExecuteNonQuery();
    
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @id";
    cmd.Parameters.Clear();
    cmd.Parameters.Add(new WitDbParameter("@amount", transferAmount));
    cmd.Parameters.Add(new WitDbParameter("@id", targetAccountId));
    cmd.ExecuteNonQuery();
    
    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

Bulk Operations with Transaction

Transactions dramatically improve performance for bulk operations:

using var connection = new WitDbConnection("Data Source=bulk.witdb");
connection.Open();

// Without transaction: ~1000 disk writes
// With transaction: 1 disk write at commit

using var transaction = connection.BeginTransaction();

using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;
cmd.CommandText = "INSERT INTO Items (Name, Value) VALUES (@name, @value)";

var nameParam = new WitDbParameter("@name", "");
var valueParam = new WitDbParameter("@value", 0);
cmd.Parameters.Add(nameParam);
cmd.Parameters.Add(valueParam);
cmd.Prepare();

var stopwatch = System.Diagnostics.Stopwatch.StartNew();

for (int i = 0; i < 10000; i++)
{
    nameParam.Value = 
Loading...
quot;Item {i}"; valueParam.Value = i * 10; cmd.ExecuteNonQuery(); } transaction.Commit(); stopwatch.Stop(); Console.WriteLine(
Loading...
quot;Inserted 10,000 rows in {stopwatch.ElapsedMilliseconds}ms"); // Typically 50-100ms with transaction vs 5-10 seconds without

Entity Framework Core Transactions

Implicit Transactions

EF Core automatically wraps SaveChanges() in a transaction:

using var context = new AppDbContext();

// All changes are in a single implicit transaction
context.Accounts.Add(new Account { Name = "Alice", Balance = 1000 });
context.Accounts.Add(new Account { Name = "Bob", Balance = 500 });

// Both inserts succeed or both fail
await context.SaveChangesAsync();

Explicit Transactions

For multiple SaveChanges() calls or complex logic:

using var context = new BankDbContext();

using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // Find accounts
    var source = await context.Accounts.FindAsync(sourceId);
    var target = await context.Accounts.FindAsync(targetId);
    
    if (source == null || target == null)
        throw new InvalidOperationException("Account not found");
    
    if (source.Balance < amount)
        throw new InvalidOperationException("Insufficient funds");
    
    // Perform transfer
    source.Balance -= amount;
    target.Balance += amount;
    
    // Log the transaction
    context.TransactionLogs.Add(new TransactionLog
    {
        FromAccountId = sourceId,
        ToAccountId = targetId,
        Amount = amount,
        Timestamp = DateTime.UtcNow
    });
    
    await context.SaveChangesAsync();
    
    // Commit both the transfer and the log
    await transaction.CommitAsync();
    
    Console.WriteLine(
Loading...
quot;Transferred {amount:C} from {source.Name} to {target.Name}"); } catch { await transaction.RollbackAsync(); throw; }

ExecutionStrategy for Retries

Handle transient failures with automatic retries:

var strategy = context.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(async () =>
{
    using var transaction = await context.Database.BeginTransactionAsync();
    
    try
    {
        // Your operations here
        source.Balance -= amount;
        target.Balance += amount;
        
        await context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
});

Core API Transactions

Basic Transaction

using OutWit.Database.Core.Builder;

using var db = WitDatabase.CreateOrOpen("app.witdb");

using var tx = db.BeginTransaction();

try
{
    tx.Put("key1"u8.ToArray(), "value1"u8.ToArray());
    tx.Put("key2"u8.ToArray(), "value2"u8.ToArray());
    tx.Delete("old-key"u8.ToArray());
    
    tx.Commit();
}
catch
{
    tx.Rollback();
    throw;
}

Async Transaction

await using var tx = await db.BeginTransactionAsync();

try
{
    await tx.PutAsync(key1, value1);
    await tx.PutAsync(key2, value2);
    
    await tx.CommitAsync();
}
catch
{
    await tx.RollbackAsync();
    throw;
}

Savepoints

Savepoints allow partial rollbacks within a transaction. Useful for complex operations where you want to undo part of the work without losing everything.

ADO.NET Savepoints

using var connection = new WitDbConnection("Data Source=app.witdb");
connection.Open();

using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    // Step 1: Create order
    cmd.CommandText = "INSERT INTO Orders (CustomerId, Total) VALUES (@custId, @total) RETURNING Id";
    cmd.Parameters.Add(new WitDbParameter("@custId", customerId));
    cmd.Parameters.Add(new WitDbParameter("@total", 0));
    var orderId = Convert.ToInt32(cmd.ExecuteScalar());
    
    Console.WriteLine(
Loading...
quot;Created order {orderId}"); // Create savepoint after order creation transaction.Save("after_order"); decimal orderTotal = 0; foreach (var item in cartItems) { // Create savepoint before each item transaction.Save("before_item"); try { // Check inventory cmd.CommandText = "SELECT Stock FROM Products WHERE Id = @productId"; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@productId", item.ProductId)); var stock = Convert.ToInt32(cmd.ExecuteScalar()); if (stock < item.Quantity) { Console.WriteLine(
Loading...
quot; Skipping {item.ProductName}: insufficient stock"); transaction.Rollback("before_item"); // Rollback just this item continue; } // Add order item cmd.CommandText = """ INSERT INTO OrderItems (OrderId, ProductId, Quantity, Price) VALUES (@orderId, @productId, @qty, @price) """; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@orderId", orderId)); cmd.Parameters.Add(new WitDbParameter("@productId", item.ProductId)); cmd.Parameters.Add(new WitDbParameter("@qty", item.Quantity)); cmd.Parameters.Add(new WitDbParameter("@price", item.Price)); cmd.ExecuteNonQuery(); // Update inventory cmd.CommandText = "UPDATE Products SET Stock = Stock - @qty WHERE Id = @productId"; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@qty", item.Quantity)); cmd.Parameters.Add(new WitDbParameter("@productId", item.ProductId)); cmd.ExecuteNonQuery(); orderTotal += item.Price * item.Quantity; Console.WriteLine(
Loading...
quot; Added {item.ProductName} x{item.Quantity}"); } catch (Exception ex) { Console.WriteLine(
Loading...
quot; Error with {item.ProductName}: {ex.Message}"); transaction.Rollback("before_item"); } } if (orderTotal == 0) { // No items added - rollback entire order transaction.Rollback("after_order"); Console.WriteLine("Order cancelled - no items available"); } else { // Update order total cmd.CommandText = "UPDATE Orders SET Total = @total WHERE Id = @orderId"; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@total", orderTotal)); cmd.Parameters.Add(new WitDbParameter("@orderId", orderId)); cmd.ExecuteNonQuery(); transaction.Commit(); Console.WriteLine(
Loading...
quot;Order {orderId} completed: {orderTotal:C}"); } } catch { transaction.Rollback(); throw; }

EF Core Savepoints

using var transaction = await context.Database.BeginTransactionAsync();

try
{
    // Create order
    var order = new Order { CustomerId = customerId };
    context.Orders.Add(order);
    await context.SaveChangesAsync();
    
    // Savepoint after order creation
    await transaction.CreateSavepointAsync("after_order");
    
    foreach (var item in cartItems)
    {
        await transaction.CreateSavepointAsync("before_item");
        
        try
        {
            var product = await context.Products.FindAsync(item.ProductId);
            
            if (product == null || product.Stock < item.Quantity)
            {
                await transaction.RollbackToSavepointAsync("before_item");
                continue;
            }
            
            order.Items.Add(new OrderItem
            {
                ProductId = item.ProductId,
                Quantity = item.Quantity,
                Price = item.Price
            });
            
            product.Stock -= item.Quantity;
            await context.SaveChangesAsync();
        }
        catch
        {
            await transaction.RollbackToSavepointAsync("before_item");
        }
    }
    
    if (!order.Items.Any())
    {
        await transaction.RollbackToSavepointAsync("after_order");
        throw new InvalidOperationException("No items could be added");
    }
    
    order.Total = order.Items.Sum(i => i.Price * i.Quantity);
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Isolation Levels

Isolation levels control how transactions interact with each other. Higher isolation prevents more anomalies but may reduce concurrency.

Available Isolation Levels

Level Dirty Reads Non-Repeatable Reads Phantom Reads Use Case
ReadUncommitted Yes Yes Yes Maximum speed, stale data OK
ReadCommitted No Yes Yes Default, good balance
RepeatableRead No No Yes Same data on re-read
Snapshot No No No Point-in-time consistency
Serializable No No No Maximum isolation

Setting Isolation Level (ADO.NET)

using System.Data;

// Specify isolation level when beginning transaction
using var transaction = connection.BeginTransaction(IsolationLevel.Snapshot);

using var cmd = connection.CreateCommand();
cmd.Transaction = transaction;

// All reads see a consistent snapshot of data
cmd.CommandText = "SELECT * FROM Accounts";
// ... reads see data as of transaction start

transaction.Commit();

Setting Isolation Level (EF Core)

using var transaction = await context.Database
    .BeginTransactionAsync(IsolationLevel.Snapshot);

// All queries in this transaction see consistent snapshot
var accounts = await context.Accounts.ToListAsync();

await transaction.CommitAsync();

Setting Default Isolation Level

// Connection string
"Data Source=app.witdb;Isolation Level=Snapshot"

// Builder
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()
    .WithTransactions()
    .WithDefaultIsolationLevel(IsolationLevel.Snapshot)
    .Build();

MVCC (Multi-Version Concurrency Control)

WitDatabase uses MVCC to allow readers and writers to work simultaneously without blocking each other.

How MVCC Works

Time →

Writer Transaction:                  Reader Transaction:
─────────────────────────────────   ─────────────────────────────────
BEGIN                               
UPDATE Account SET Balance=900      BEGIN
                                    SELECT Balance FROM Account
                                    → Returns 1000 (old version)
COMMIT                              
                                    SELECT Balance FROM Account  
                                    → Still returns 1000 (snapshot)
                                    COMMIT
                                    
                                    BEGIN (new transaction)
                                    SELECT Balance FROM Account
                                    → Returns 900 (committed value)

Benefits:

  • Readers don't block writers
  • Writers don't block readers
  • Consistent reads throughout a transaction
  • No dirty reads

Enabling MVCC

// Connection string (enabled by default)
"Data Source=app.witdb;MVCC=true"

// Builder
var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()
    .WithTransactions()
    .WithMvcc()
    .Build();

Complete Example: Bank Transfer System

using System.Data;
using OutWit.Database.AdoNet;

// ═══════════════════════════════════════════════════════════════════
// Bank Transfer System - Transaction Example
// ═══════════════════════════════════════════════════════════════════

using var connection = new WitDbConnection("Data Source=bank.witdb");
connection.Open();

// Setup database
InitializeDatabase(connection);

Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
Console.WriteLine("║              Bank Transfer System                            ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════════╝\n");

// Show initial balances
ShowBalances(connection);

// Perform some transfers
Console.WriteLine("\nPerforming transfers:");
Console.WriteLine("─".PadRight(50, '─'));

Transfer(connection, "Alice", "Bob", 200);
Transfer(connection, "Bob", "Charlie", 50);
Transfer(connection, "Charlie", "Alice", 75);

// This should fail - insufficient funds
Transfer(connection, "Alice", "Bob", 10000);

// Show final balances
Console.WriteLine("\nFinal balances:");
ShowBalances(connection);

// Show transaction history
ShowHistory(connection);

// ═══════════════════════════════════════════════════════════════════
// Helper Methods
// ═══════════════════════════════════════════════════════════════════

static void InitializeDatabase(WitDbConnection conn)
{
    using var cmd = conn.CreateCommand();
    
    cmd.CommandText = """
        CREATE TABLE IF NOT EXISTS Accounts (
            Id INTEGER PRIMARY KEY AUTOINCREMENT,
            Name VARCHAR(100) NOT NULL UNIQUE,
            Balance DECIMAL(18,2) NOT NULL DEFAULT 0 CHECK (Balance >= 0)
        )
        """;
    cmd.ExecuteNonQuery();
    
    cmd.CommandText = """
        CREATE TABLE IF NOT EXISTS Transactions (
            Id INTEGER PRIMARY KEY AUTOINCREMENT,
            FromAccount VARCHAR(100),
            ToAccount VARCHAR(100),
            Amount DECIMAL(18,2) NOT NULL,
            Status VARCHAR(20) NOT NULL,
            Timestamp DATETIME DEFAULT NOW(),
            ErrorMessage TEXT
        )
        """;
    cmd.ExecuteNonQuery();
    
    // Seed accounts if empty
    cmd.CommandText = "SELECT COUNT(*) FROM Accounts";
    if (Convert.ToInt32(cmd.ExecuteScalar()) == 0)
    {
        cmd.CommandText = """
            INSERT INTO Accounts (Name, Balance) VALUES 
            ('Alice', 1000.00),
            ('Bob', 500.00),
            ('Charlie', 250.00)
            """;
        cmd.ExecuteNonQuery();
    }
}

static void Transfer(WitDbConnection conn, string fromName, string toName, decimal amount)
{
    using var transaction = conn.BeginTransaction(IsolationLevel.Serializable);
    
    try
    {
        using var cmd = conn.CreateCommand();
        cmd.Transaction = transaction;
        
        // Get source account
        cmd.CommandText = "SELECT Id, Balance FROM Accounts WHERE Name = @name";
        cmd.Parameters.Add(new WitDbParameter("@name", fromName));
        
        int fromId;
        decimal fromBalance;
        
        using (var reader = cmd.ExecuteReader())
        {
            if (!reader.Read())
                throw new InvalidOperationException(
Loading...
quot;Account '{fromName}' not found"); fromId = reader.GetInt32(0); fromBalance = reader.GetDecimal(1); } // Get target account cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@name", toName)); int toId; using (var reader = cmd.ExecuteReader()) { if (!reader.Read()) throw new InvalidOperationException(
Loading...
quot;Account '{toName}' not found"); toId = reader.GetInt32(0); } // Check sufficient funds if (fromBalance < amount) { throw new InvalidOperationException(
Loading...
quot;Insufficient funds: {fromName} has {fromBalance:C}, needs {amount:C}"); } // Debit source cmd.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE Id = @id"; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@amount", amount)); cmd.Parameters.Add(new WitDbParameter("@id", fromId)); cmd.ExecuteNonQuery(); // Credit target cmd.CommandText = "UPDATE Accounts SET Balance = Balance + @amount WHERE Id = @id"; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@amount", amount)); cmd.Parameters.Add(new WitDbParameter("@id", toId)); cmd.ExecuteNonQuery(); // Log successful transaction LogTransaction(cmd, fromName, toName, amount, "SUCCESS", null); transaction.Commit(); Console.WriteLine(
Loading...
quot; ✓ {fromName} → {toName}: {amount:C}"); } catch (Exception ex) { transaction.Rollback(); // Log failed transaction (in a new transaction) using var logCmd = conn.CreateCommand(); LogTransaction(logCmd, fromName, toName, amount, "FAILED", ex.Message); Console.WriteLine(
Loading...
quot; ✗ {fromName} → {toName}: {amount:C} - {ex.Message}"); } } static void LogTransaction(WitDbCommand cmd, string from, string to, decimal amount, string status, string? error) { cmd.CommandText = """ INSERT INTO Transactions (FromAccount, ToAccount, Amount, Status, ErrorMessage) VALUES (@from, @to, @amount, @status, @error) """; cmd.Parameters.Clear(); cmd.Parameters.Add(new WitDbParameter("@from", from)); cmd.Parameters.Add(new WitDbParameter("@to", to)); cmd.Parameters.Add(new WitDbParameter("@amount", amount)); cmd.Parameters.Add(new WitDbParameter("@status", status)); cmd.Parameters.Add(new WitDbParameter("@error", error ?? (object)DBNull.Value)); cmd.ExecuteNonQuery(); } static void ShowBalances(WitDbConnection conn) { using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT Name, Balance FROM Accounts ORDER BY Name"; Console.WriteLine("Account Balances:"); Console.WriteLine("─".PadRight(30, '─')); using var reader = cmd.ExecuteReader(); while (reader.Read()) { Console.WriteLine(
Loading...
quot; {reader["Name"],-15} {reader.GetDecimal(1),12:C}"); } } static void ShowHistory(WitDbConnection conn) { using var cmd = conn.CreateCommand(); cmd.CommandText = """ SELECT FromAccount, ToAccount, Amount, Status, Timestamp FROM Transactions ORDER BY Timestamp DESC LIMIT 10 """; Console.WriteLine("\nRecent Transactions:"); Console.WriteLine("─".PadRight(70, '─')); using var reader = cmd.ExecuteReader(); while (reader.Read()) { var status = reader["Status"].ToString() == "SUCCESS" ? "✓" : "✗"; var timestamp = reader.GetDateTime(4).ToString("HH:mm:ss"); Console.WriteLine(
Loading...
quot; {status} [{timestamp}] {reader["FromAccount"]} → {reader["ToAccount"]}: {reader.GetDecimal(2):C}"); } }

Sample output:

╔══════════════════════════════════════════════════════════════╗
║              Bank Transfer System                            ║
╚══════════════════════════════════════════════════════════════╝

Account Balances:
──────────────────────────────
  Alice              $1,000.00
  Bob                  $500.00
  Charlie              $250.00

Performing transfers:
──────────────────────────────────────────────────
  ✓ Alice → Bob: $200.00
  ✓ Bob → Charlie: $50.00
  ✓ Charlie → Alice: $75.00
  ✗ Alice → Bob: $10,000.00 - Insufficient funds: Alice has $875.00, needs $10,000.00

Final balances:
Account Balances:
──────────────────────────────
  Alice                $875.00
  Bob                  $650.00
  Charlie              $225.00

Recent Transactions:
──────────────────────────────────────────────────────────────────────
  ✗ [14:32:01] Alice → Bob: $10,000.00
  ✓ [14:32:01] Charlie → Alice: $75.00
  ✓ [14:32:01] Bob → Charlie: $50.00
  ✓ [14:32:01] Alice → Bob: $200.00

Transactions Summary

API Begin Transaction Commit Rollback
ADO.NET connection.BeginTransaction() transaction.Commit() transaction.Rollback()
EF Core context.Database.BeginTransaction() transaction.Commit() transaction.Rollback()
Core API db.BeginTransaction() tx.Commit() tx.Rollback()

Best practices:

  • ✅ Always use transactions for multi-step operations
  • ✅ Keep transactions short — don't hold them during user input
  • ✅ Use using to ensure proper disposal
  • ✅ Use savepoints for complex operations with partial rollback needs
  • ✅ Choose appropriate isolation level for your use case
  • ✅ Enable MVCC for better concurrency

Quick Reference

A handy reference card for common WitDatabase operations. Bookmark this page!

Connection Strings

Common Patterns:

// Simple file database
"Data Source=app.witdb"

// In-memory (testing)
"Data Source=:memory:"

// With encryption
"Data Source=secure.witdb;Encryption=aes-gcm;Password=MySecret123!"

// ChaCha20 for Blazor WASM
"Data Source=app.witdb;Encryption=chacha20;Password=secret"

// Read-only mode
"Data Source=app.witdb;Mode=ReadOnly"

// With all options
"Data Source=app.witdb;Store=btree;Encryption=aes-gcm;Password=secret;MVCC=true;Isolation Level=Snapshot"

All Parameters

Parameter Values Default Description
Data Source path or :memory: Database location
Mode ReadOnly, ReadWrite, ReadWriteCreate ReadWriteCreate Access mode
Store BTree, LSM BTree Storage engine
Encryption None, aes-gcm, chacha20 None Encryption algorithm
Password string Encryption password
User string User for key derivation
Fast Encryption true, false false Reduced PBKDF2 iterations
MVCC true, false true Multi-version concurrency
Isolation Level ReadUncommitted, ReadCommitted, RepeatableRead, Snapshot, Serializable ReadCommitted Default isolation
Parallel Mode None, Auto, Latched, Buffered None Concurrency mode
Cache Size integer 1000 Pages in cache
Page Size integer 4096 Bytes per page
Pooling true, false true Connection pooling

Core API (Key-Value)

using OutWit.Database.Core.Builder;

// Create / Open
using var db = WitDatabase.CreateInMemory();
using var db = WitDatabase.Create("app.witdb");
using var db = WitDatabase.Open("app.witdb");
using var db = WitDatabase.CreateOrOpen("app.witdb");
using var db = WitDatabase.CreateOrOpen("app.witdb", "password");

// Builder pattern
using var db = new WitDatabaseBuilder()
    .WithFilePath("app.witdb")
    .WithBTree()                    // or .WithLsmTree()
    .WithEncryption("password")     // or .WithBouncyCastleEncryption()
    .WithTransactions()
    .WithMvcc()
    .Build();

// Basic operations
db.Put(key, value);                 // Store
var value = db.Get(key);            // Retrieve (null if not found)
db.Delete(key);                     // Remove
bool exists = db.Exists(key);       // Check existence

// Async versions
await db.PutAsync(key, value);
var value = await db.GetAsync(key);
await db.DeleteAsync(key);

// Scanning
foreach (var (k, v) in db.Scan(startKey, endKey))
{
    // Process key-value pairs in order
}

// Transactions
using var tx = db.BeginTransaction();
tx.Put(key1, value1);
tx.Put(key2, value2);
tx.Commit();  // or tx.Rollback();

ADO.NET

Connection

using OutWit.Database.AdoNet;

using var connection = new WitDbConnection("Data Source=app.witdb");
connection.Open();
// ... use connection
// Automatically closed and disposed

Execute Commands

using var cmd = connection.CreateCommand();

// Non-query (CREATE, INSERT, UPDATE, DELETE)
cmd.CommandText = "CREATE TABLE Users (Id INTEGER PRIMARY KEY, Name TEXT)";
int rowsAffected = cmd.ExecuteNonQuery();

// Scalar (single value)
cmd.CommandText = "SELECT COUNT(*) FROM Users";
var count = cmd.ExecuteScalar();

// Reader (multiple rows)
cmd.CommandText = "SELECT * FROM Users";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
    var id = reader.GetInt32(0);
    var name = reader["Name"].ToString();
}

Parameters

cmd.CommandText = "INSERT INTO Users (Name, Email) VALUES (@name, @email)";
cmd.Parameters.Add(new WitDbParameter("@name", "Alice"));
cmd.Parameters.Add(new WitDbParameter("@email", "alice@example.com"));
cmd.ExecuteNonQuery();

// Clear for reuse
cmd.Parameters.Clear();

Transactions

using var transaction = connection.BeginTransaction();

try
{
    using var cmd = connection.CreateCommand();
    cmd.Transaction = transaction;
    
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance - 100 WHERE Id = 1";
    cmd.ExecuteNonQuery();
    
    cmd.CommandText = "UPDATE Accounts SET Balance = Balance + 100 WHERE Id = 2";
    cmd.ExecuteNonQuery();
    
    transaction.Commit();
}
catch
{
    transaction.Rollback();
    throw;
}

Async

await connection.OpenAsync();

using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT * FROM Users";

await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
    // Process row
}

Entity Framework Core

DbContext Setup

using Microsoft.EntityFrameworkCore;
using OutWit.Database.EntityFramework;

public class AppDbContext : DbContext
{
    public DbSet<User> Users => Set<User>();
    public DbSet<Post> Posts => Set<Post>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseWitDb("Data Source=app.witdb");
}

// With DI (ASP.NET Core)
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseWitDb("Data Source=app.witdb"));

CRUD Operations

using var context = new AppDbContext();
context.Database.EnsureCreated();

// Create
context.Users.Add(new User { Name = "Alice" });
await context.SaveChangesAsync();

// Read
var users = await context.Users.ToListAsync();
var user = await context.Users.FindAsync(1);
var alice = await context.Users.FirstOrDefaultAsync(u => u.Name == "Alice");

// Update
user.Name = "Alice Smith";
await context.SaveChangesAsync();

// Delete
context.Users.Remove(user);
await context.SaveChangesAsync();

Common LINQ Queries

// Filter
var active = context.Users.Where(u => u.IsActive);

// Sort
var sorted = context.Users.OrderBy(u => u.Name);
var sortedDesc = context.Users.OrderByDescending(u => u.CreatedAt);

// Select specific columns
var names = context.Users.Select(u => new { u.Id, u.Name });

// Pagination
var page = context.Users
    .OrderBy(u => u.Id)
    .Skip(20)
    .Take(10);

// Aggregates
var count = await context.Users.CountAsync();
var any = await context.Users.AnyAsync(u => u.IsAdmin);
var max = await context.Users.MaxAsync(u => u.Age);

// Include related data
var usersWithPosts = context.Users
    .Include(u => u.Posts)
    .ToList();

// Group
var byCountry = context.Users
    .GroupBy(u => u.Country)
    .Select(g => new { Country = g.Key, Count = g.Count() });

Transactions

// Implicit (single SaveChanges)
context.Users.Add(user1);
context.Users.Add(user2);
await context.SaveChangesAsync();  // All or nothing

// Explicit
using var transaction = await context.Database.BeginTransactionAsync();
try
{
    // Multiple operations
    await context.SaveChangesAsync();
    await context.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

SQL Reference

Data Types

SQL Type C# Type Notes
INTEGER int, long 64-bit
REAL double 64-bit float
TEXT string UTF-8
BLOB byte[] Binary
BOOLEAN bool 0/1
DATETIME DateTime ISO 8601
DECIMAL(p,s) decimal Exact numeric
VARCHAR(n) string Max length
GUID Guid UUID

DDL (Data Definition)

-- Create table
CREATE TABLE Users (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    Name VARCHAR(100) NOT NULL,
    Email VARCHAR(255) UNIQUE,
    Age INTEGER DEFAULT 0,
    IsActive BOOLEAN DEFAULT TRUE,
    CreatedAt DATETIME DEFAULT NOW()
);

-- Create index
CREATE INDEX idx_users_email ON Users(Email);
CREATE UNIQUE INDEX idx_users_name ON Users(Name);

-- Drop
DROP TABLE IF EXISTS Users;
DROP INDEX IF EXISTS idx_users_email;

-- Alter (limited support)
ALTER TABLE Users ADD COLUMN Phone VARCHAR(20);

DML (Data Manipulation)

-- Insert
INSERT INTO Users (Name, Email) VALUES ('Alice', 'alice@example.com');
INSERT INTO Users (Name, Email) VALUES ('Bob', 'bob@example.com') RETURNING Id;

-- Select
SELECT * FROM Users;
SELECT Id, Name FROM Users WHERE IsActive = TRUE ORDER BY Name LIMIT 10;
SELECT COUNT(*), Country FROM Users GROUP BY Country HAVING COUNT(*) > 5;

-- Update
UPDATE Users SET Name = 'Alice Smith' WHERE Id = 1;
UPDATE Users SET IsActive = FALSE WHERE LastLogin < '2024-01-01' RETURNING Id, Name;

-- Delete
DELETE FROM Users WHERE Id = 1;
DELETE FROM Users WHERE IsActive = FALSE RETURNING Id;

-- Upsert
INSERT INTO Users (Id, Name) VALUES (1, 'Alice')
ON CONFLICT(Id) DO UPDATE SET Name = excluded.Name;

Joins

-- Inner join
SELECT u.Name, p.Title 
FROM Users u 
INNER JOIN Posts p ON u.Id = p.UserId;

-- Left join
SELECT u.Name, COUNT(p.Id) as PostCount
FROM Users u
LEFT JOIN Posts p ON u.Id = p.UserId
GROUP BY u.Id;

Common Functions

-- String
LENGTH(str), UPPER(str), LOWER(str), TRIM(str)
SUBSTR(str, start, length), REPLACE(str, old, new)
CONCAT(str1, str2), INSTR(str, substr)

-- Numeric
ABS(n), ROUND(n, decimals), CEIL(n), FLOOR(n)
MIN(n), MAX(n), SUM(n), AVG(n), COUNT(*)

-- Date/Time
NOW(), DATE('now'), TIME('now')
STRFTIME('%Y-%m-%d', datetime_col)

-- Null handling
COALESCE(val1, val2, ...), IFNULL(val, default), NULLIF(val1, val2)

-- Conditional
CASE WHEN condition THEN result ELSE default END
IIF(condition, true_val, false_val)

Common Patterns

"I want to..." Quick Guide

I want to... Solution
Create a simple database WitDatabase.CreateOrOpen("app.witdb")
Use SQL queries Install OutWit.Database.AdoNet, use WitDbConnection
Use EF Core Install OutWit.Database.EntityFramework, use UseWitDb()
Encrypt my database Add Encryption=aes-gcm;Password=secret to connection string
Use in Blazor WASM Install .Core.IndexedDb, use WithIndexedDbStorage()
Insert many rows fast Wrap in transaction, use Prepare()
Handle concurrent access Add Parallel Mode=Auto to connection string
Use in unit tests Use Data Source=:memory: for isolated in-memory DB
Store JSON objects Serialize to byte[] with System.Text.Json
Get last inserted ID Use RETURNING Id clause or LAST_INSERT_ROWID()

Error Handling

try
{
    using var connection = new WitDbConnection(connectionString);
    connection.Open();
    // ... operations
}
catch (WitDbException ex) when (ex.Message.Contains("locked"))
{
    // Database is locked by another process
    Console.WriteLine("Database is busy, try again later");
}
catch (WitDbException ex) when (ex.Message.Contains("decrypt"))
{
    // Wrong password
    Console.WriteLine("Invalid password");
}
catch (WitDbException ex)
{
    // Other database errors
    Console.WriteLine(
Loading...
quot;Database error: {ex.Message}"); }

Testing Pattern

public class UserServiceTests
{
    private AppDbContext CreateTestContext()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseWitDb("Data Source=:memory:")
            .Options;
        
        var context = new AppDbContext(options);
        context.Database.EnsureCreated();
        return context;
    }

    [Fact]
    public async Task CreateUser_ShouldAssignId()
    {
        // Arrange
        using var context = CreateTestContext();
        var service = new UserService(context);
        
        // Act
        var user = await service.CreateUserAsync("Alice", "alice@test.com");
        
        // Assert
        Assert.True(user.Id > 0);
        Assert.Equal("Alice", user.Name);
    }
}

Repository Pattern

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
    Task UpdateAsync(T entity);
    Task DeleteAsync(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    private readonly AppDbContext _context;
    private readonly DbSet<T> _dbSet;

    public Repository(AppDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public async Task<T?> GetByIdAsync(int id) 
        => await _dbSet.FindAsync(id);

    public async Task<IEnumerable<T>> GetAllAsync() 
        => await _dbSet.ToListAsync();

    public async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(T entity)
    {
        _dbSet.Update(entity);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(T entity)
    {
        _dbSet.Remove(entity);
        await _context.SaveChangesAsync();
    }
}

Package Reference

Package Purpose Install
OutWit.Database.Core Key-value API dotnet add package OutWit.Database.Core
OutWit.Database.AdoNet SQL + ADO.NET dotnet add package OutWit.Database.AdoNet
OutWit.Database.EntityFramework EF Core dotnet add package OutWit.Database.EntityFramework
OutWit.Database.Core.IndexedDb Blazor WASM dotnet add package OutWit.Database.Core.IndexedDb
OutWit.Database.Core.BouncyCastle ChaCha20 dotnet add package OutWit.Database.Core.BouncyCastle