The SQL Engine (WitSqlEngine) provides direct access to WitDatabase's query execution capabilities without the ADO.NET abstraction layer. It offers the best performance for SQL operations while maintaining a simple, straightforward API.
Overview
What is the SQL Engine?
The SQL Engine is the heart of WitDatabase's SQL processing. It parses SQL statements, plans query execution, and returns results. While ADO.NET wraps this engine with standard .NET interfaces (DbConnection, DbCommand, DbDataReader), you can also use the engine directly for maximum performance.
When you execute a query through ADO.NET, the path looks like this:
Your Code → WitDbCommand → WitSqlEngine → StorageUsing the SQL Engine directly eliminates the middle layer:
Your Code → WitSqlEngine → StorageThis direct access removes the overhead of ADO.NET's connection state management, parameter collection objects, and result set abstraction. For high-throughput scenarios or tight loops, this overhead reduction translates to measurably better performance.
When to Use the SQL Engine Directly
The SQL Engine is ideal for:
Maximum performance scenarios. When you're processing millions of rows or executing thousands of queries per second, eliminating ADO.NET overhead matters. Benchmarks show 10-20% improvement for simple queries.
Embedded database logic. When building a service or library that uses WitDatabase internally, the SQL Engine provides a cleaner API than creating fake connection strings and managing DbConnection lifecycle.
Bulk data operations. The engine's BulkInsert and prepared statement batch execution are optimized for high-throughput data loading.
Long-running processes. Background services, data pipelines, and ETL jobs that maintain a single database context benefit from the simpler lifecycle model.
Low-level data access. When you need direct table scans, index seeks, or schema introspection beyond what SQL provides.
When to Use ADO.NET Instead
Stick with ADO.NET when:
- You need compatibility with existing code or libraries expecting
DbConnection - You're using Entity Framework Core
- You want connection pooling managed automatically
- You need
TransactionScopesupport - Interoperability with other ADO.NET providers matters
The ADO.NET provider is built on top of the SQL Engine, so you're not losing any functionality — just gaining compatibility.
Architecture Overview
The SQL Engine sits between your code and the storage layer:
Getting Started
Creating an Engine Instance
The SQL Engine requires a WitDatabase instance, which you create through the builder pattern:
using OutWit.Database.Core.Builder;
using OutWit.Database.Engine;
// Create database with desired configuration
var database = new WitDatabaseBuilder()
.WithFilePath("myapp.witdb")
.WithBTree()
.WithTransactions()
.Build();
// Create the SQL engine
var engine = new WitSqlEngine(database, ownsStore: true);The ownsStore parameter determines disposal behavior:
true— Engine disposes the database when the engine is disposedfalse— You manage the database lifecycle separately
For in-memory databases, use the convenience method:
var database = WitDatabase.CreateInMemory();
var engine = new WitSqlEngine(database, ownsStore: true);Basic Query Execution
The engine provides several methods for different query patterns:
// Execute returns a WitSqlResult for any SQL
using var result = engine.Execute("SELECT * FROM Users");
// Iterate through rows
while (result.Read())
{
var id = result.CurrentRow["Id"].AsInt64();
var name = result.CurrentRow["Name"].AsString();
Console.WriteLine($"{id}: {name}");
}For simpler access patterns, use the convenience methods:
// Get all rows as a list
List<WitSqlRow> users = engine.Query("SELECT * FROM Users WHERE IsActive = 1");
// Get first row or null
WitSqlRow? user = engine.QueryFirstOrDefault("SELECT * FROM Users WHERE Id = 1");
// Get a single scalar value
WitSqlValue count = engine.ExecuteScalar("SELECT COUNT(*) FROM Users");
Console.WriteLine($"User count: {count.AsInt64()}");
// Execute non-query (INSERT, UPDATE, DELETE) and get rows affected
int affected = engine.ExecuteNonQuery("UPDATE Users SET LastLogin = NOW() WHERE Id = 1");Working with Parameters
Never concatenate user input into SQL strings. Use parameters:
var parameters = new Dictionary<string, object?>
{
["@name"] = "Alice",
["@email"] = "alice@example.com"
};
var user = engine.QueryFirstOrDefault(
"SELECT * FROM Users WHERE Name = @name AND Email = @email",
parameters);Parameter names can use @, :, or $ prefix — all are equivalent:
// All equivalent
engine.Execute("SELECT * FROM Users WHERE Id = @id", new Dictionary<string, object?> { ["id"] = 1 });
engine.Execute("SELECT * FROM Users WHERE Id = :id", new Dictionary<string, object?> { ["id"] = 1 });
engine.Execute("SELECT * FROM Users WHERE Id = $id", new Dictionary<string, object?> { ["id"] = 1 });Timeout and Cancellation
For long-running queries, specify a timeout or use cancellation tokens:
// With timeout
var timeout = TimeSpan.FromSeconds(30);
using var result = engine.Execute(
"SELECT * FROM LargeTable WHERE ComplexCondition(Column)",
parameters: null,
timeout: timeout);
// With CancellationToken
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
using var result = engine.Execute(
"SELECT * FROM LargeTable",
parameters: null,
timeout: null,
cancellationToken: cts.Token);Cancellation is cooperative — the engine checks the token between row iterations. For very long-running single operations (like a massive JOIN), the check happens at natural boundaries in processing.
// Practical example: cancellable report generation
public async Task<List<ReportRow>> GenerateReportAsync(CancellationToken cancellationToken)
{
var results = new List<ReportRow>();
using var result = engine.Execute(
"SELECT * FROM Orders WHERE Date > @start",
new Dictionary<string, object?> { ["start"] = DateTime.Today.AddMonths(-12) },
timeout: TimeSpan.FromMinutes(5),
cancellationToken: cancellationToken);
while (result.Read())
{
cancellationToken.ThrowIfCancellationRequested();
results.Add(MapToReportRow(result.CurrentRow));
}
return results;
}Creating Tables
Execute DDL statements the same way as queries:
engine.Execute(@"
CREATE TABLE Users (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Name VARCHAR(100) NOT NULL,
Email VARCHAR(255) UNIQUE,
CreatedAt DATETIME DEFAULT NOW(),
IsActive BOOLEAN DEFAULT true
)
");
engine.Execute(@"
CREATE INDEX IX_Users_Email ON Users(Email)
");The WitSqlResult Class
Every query returns a WitSqlResult that provides access to results and metadata.
Result Properties
using var result = engine.Execute("UPDATE Users SET IsActive = 0 WHERE LastLogin < @date",
new Dictionary<string, object?> { ["date"] = DateTime.UtcNow.AddYears(-1) });
// Number of rows affected by INSERT/UPDATE/DELETE
Console.WriteLine($"Rows affected: {result.RowsAffected}");
// Column information
Console.WriteLine($"Columns: {result.Columns.Count}");
foreach (var col in result.Columns)
{
Console.WriteLine($" {col.Name}: {col.Type}");
}
// For SELECT with RETURNING or INSERT...RETURNING
Console.WriteLine($"Has rows: {result.HasRows}");Reading Rows
The result acts as a forward-only cursor:
using var result = engine.Execute("SELECT Id, Name, Email FROM Users");
while (result.Read())
{
// Access by column name
var name = result.CurrentRow["Name"].AsString();
// Access by index
var id = result.CurrentRow[0].AsInt64();
// The CurrentRow property returns a WitSqlRow
WitSqlRow row = result.CurrentRow;
}For convenience, read all rows at once:
using var result = engine.Execute("SELECT * FROM Users");
List<WitSqlRow> allRows = result.ReadAll();Working with WitSqlRow
Each row provides column access and type conversion:
WitSqlRow row = result.CurrentRow;
// Column access by name or index
WitSqlValue idValue = row["Id"];
WitSqlValue nameValue = row[1];
// Type conversion methods
long id = row["Id"].AsInt64();
string name = row["Name"].AsString();
decimal? price = row["Price"].AsDecimalOrNull();
DateTime created = row["CreatedAt"].AsDateTime();
bool isActive = row["IsActive"].AsBoolean();
// Check for NULL
if (row["MiddleName"].IsNull)
{
Console.WriteLine("No middle name");
}
// Get column names
IReadOnlyList<string> columns = row.ColumnNames;WitSqlValue Type Conversions
The WitSqlValue struct provides type-safe access with null handling:
| Method | Returns | Null Behavior |
|---|---|---|
AsInt64() |
long |
Throws if null |
AsInt64OrNull() |
long? |
Returns null |
AsInt64OrDefault(defaultValue) |
long |
Returns default |
AsDouble() |
double |
Throws if null |
AsDoubleOrNull() |
double? |
Returns null |
AsDecimal() |
decimal |
Throws if null |
AsDecimalOrNull() |
decimal? |
Returns null |
AsString() |
string |
Throws if null |
AsStringOrNull() |
string? |
Returns null |
AsBoolean() |
bool |
Throws if null |
AsBooleanOrNull() |
bool? |
Returns null |
AsDateTime() |
DateTime |
Throws if null |
AsDateTimeOrNull() |
DateTime? |
Returns null |
AsGuid() |
Guid |
Throws if null |
AsGuidOrNull() |
Guid? |
Returns null |
AsBytes() |
byte[] |
Throws if null |
AsBytesOrNull() |
byte[]? |
Returns null |
IsNull |
bool |
Check for null |
Prepared Statements
Prepared statements parse SQL once and execute multiple times with different parameters. They're essential for high-performance batch operations.
Creating a Prepared Statement
using var stmt = engine.Prepare("SELECT * FROM Users WHERE Department = @dept");
// Set parameters
stmt.SetParameter("dept", "Engineering");
// Execute
using var result = stmt.Execute();
while (result.Read())
{
Console.WriteLine(result.CurrentRow["Name"].AsString());
}Reusing with Different Parameters
using var stmt = engine.Prepare("SELECT * FROM Products WHERE Category = @cat AND Price < @maxPrice");
// First execution
stmt.SetParameter("cat", "Electronics");
stmt.SetParameter("maxPrice", 500.00m);
using var electronics = stmt.Execute();
// Process electronics...
// Clear and reuse
stmt.ClearParameters();
stmt.SetParameter("cat", "Clothing");
stmt.SetParameter("maxPrice", 100.00m);
using var clothing = stmt.Execute();
// Process clothing...Fluent Parameter Setting
Chain parameter calls for cleaner code:
using var stmt = engine.Prepare("INSERT INTO Users (Name, Email, Age) VALUES (@name, @email, @age)");
using var result = stmt
.SetParameter("name", "Alice")
.SetParameter("email", "alice@example.com")
.SetParameter("age", 30)
.Execute();Or use a dictionary:
stmt.SetParameters(new Dictionary<string, object?>
{
["name"] = "Bob",
["email"] = "bob@example.com",
["age"] = 25
});
using var result = stmt.Execute();Execution with Timeout and Cancellation
Prepared statements also support timeout and cancellation:
using var stmt = engine.Prepare("SELECT * FROM LargeTable WHERE Category = @cat");
stmt.SetParameter("cat", "Reports");
// With CancellationToken
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var result = stmt.Execute(cts.Token);
// Process with cancellation checks
while (result.Read())
{
cts.Token.ThrowIfCancellationRequested();
ProcessRow(result.CurrentRow);
}Batch Execution
For inserting or updating many rows, batch execution is dramatically faster:
using var stmt = engine.Prepare("INSERT INTO Users (Name, Email) VALUES (@name, @email)");
var users = new[]
{
new Dictionary<string, object?> { ["name"] = "Alice", ["email"] = "alice@test.com" },
new Dictionary<string, object?> { ["name"] = "Bob", ["email"] = "bob@test.com" },
new Dictionary<string, object?> { ["name"] = "Charlie", ["email"] = "charlie@test.com" },
};
int rowsAffected = stmt.ExecuteBatch(users);
Console.WriteLine($"Inserted {rowsAffected} users");For large datasets, batch execution provides significant performance benefits:
- SQL is parsed once
- Execution context is reused
- Minimal object allocation per row
Batch execution also supports cancellation:
var cts = new CancellationTokenSource();
// Cancel after 1 minute
cts.CancelAfter(TimeSpan.FromMinutes(1));
try
{
int affected = stmt.ExecuteBatch(millionsOfRows, cts.Token);
Console.WriteLine($"Inserted {affected} rows");
}
catch (OperationCanceledException)
{
Console.WriteLine("Batch operation was cancelled");
}Generic Batch Execution
Use objects directly with property-to-parameter mapping:
public record UserDto(string Name, string Email, int Age);
using var stmt = engine.Prepare("INSERT INTO Users (Name, Email, Age) VALUES (@Name, @Email, @Age)");
var users = new[]
{
new UserDto("Alice", "alice@test.com", 30),
new UserDto("Bob", "bob@test.com", 25),
new UserDto("Charlie", "charlie@test.com", 35),
};
int rowsAffected = stmt.ExecuteBatch(users); // Properties mapped to parametersBulk Operations
The SQL Engine provides optimized bulk operations for high-throughput data loading.
BulkInsert with Arrays
Insert many rows with minimal overhead:
var columns = new[] { "Name", "Email", "Age" };
var rows = new List<object?[]>
{
new object?[] { "Alice", "alice@test.com", 25 },
new object?[] { "Bob", "bob@test.com", 30 },
new object?[] { "Charlie", "charlie@test.com", 35 },
};
int inserted = engine.BulkInsert("Users", columns, rows);
Console.WriteLine($"Inserted {inserted} rows");BulkInsert with Dictionaries
When column sets vary or for cleaner code:
var rows = new[]
{
new Dictionary<string, object?> { ["Name"] = "Alice", ["Email"] = "alice@test.com" },
new Dictionary<string, object?> { ["Name"] = "Bob", ["Email"] = "bob@test.com" },
};
int inserted = engine.BulkInsert("Users", rows);BulkInsert with Cancellation
For very large bulk operations, use cancellation:
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
try
{
int inserted = engine.BulkInsert("Events", columns, millionsOfRows, cts.Token);
Console.WriteLine($"Inserted {inserted} rows");
}
catch (OperationCanceledException)
{
Console.WriteLine("Bulk insert was cancelled");
// Partial data may have been inserted if not in a transaction
}Performance Comparison
For inserting 10,000 rows:
| Method | Time | Notes |
|---|---|---|
| Individual INSERTs (no transaction) | ~30s | Each INSERT auto-commits |
| Individual INSERTs (in transaction) | ~1s | Single commit at end |
| Prepared statement batch | ~0.5s | Parse once, reuse |
| BulkInsert | ~0.3s | Optimized internal path |
Always wrap bulk operations in a transaction for best performance:
engine.Execute("BEGIN TRANSACTION");
try
{
engine.BulkInsert("Users", columns, rows);
engine.Execute("COMMIT");
}
catch
{
engine.Execute("ROLLBACK");
throw;
}Transactions
The SQL Engine supports full ACID transactions with multiple isolation levels.
Basic Transaction Pattern
engine.Execute("BEGIN TRANSACTION");
try
{
engine.Execute("UPDATE Accounts SET Balance = Balance - 100 WHERE Id = 1");
engine.Execute("UPDATE Accounts SET Balance = Balance + 100 WHERE Id = 2");
engine.Execute("COMMIT");
}
catch
{
engine.Execute("ROLLBACK");
throw;
}Isolation Levels
Set the isolation level for a transaction:
engine.Execute("BEGIN TRANSACTION");
engine.Execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
// Your queries run with serializable isolation
// ...
engine.Execute("COMMIT");Available isolation levels:
| Level | Description |
|---|---|
READ UNCOMMITTED |
Can see uncommitted changes from other transactions |
READ COMMITTED |
Only sees committed data (default) |
REPEATABLE READ |
Same reads return same data within transaction |
SERIALIZABLE |
Full isolation, as if transactions ran sequentially |
SNAPSHOT |
Each transaction sees a consistent snapshot |
Savepoints
Create savepoints for partial rollback:
engine.Execute("BEGIN TRANSACTION");
engine.Execute("INSERT INTO Orders (CustomerId, Total) VALUES (1, 100)");
engine.Execute("SAVEPOINT order_created");
try
{
engine.Execute("INSERT INTO OrderItems (OrderId, ProductId) VALUES (LAST_INSERT_ID(), 999)");
// This might fail if product 999 doesn't exist
}
catch
{
engine.Execute("ROLLBACK TO order_created");
// Order still exists, but without items
}
engine.Execute("COMMIT");Transaction via ITransaction Interface
For more control, use the database's transaction interface:
using var transaction = database.BeginTransaction();
try
{
// Operations within transaction context...
engine.Execute("INSERT INTO Logs (Message) VALUES ('Test')");
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}Query Analysis with EXPLAIN
Understanding how the engine executes your queries helps you write efficient SQL. The EXPLAIN statement shows the query execution plan without actually running the query.
Basic EXPLAIN
using var result = engine.Execute("EXPLAIN SELECT * FROM Users WHERE Email = 'alice@test.com'");
while (result.Read())
{
Console.WriteLine(result.CurrentRow["Plan"].AsString());
}Output might look like:
Scan: Users
Filter: Email = 'alice@test.com'
Estimated rows: 1Understanding Execution Plans
The execution plan shows the operations the engine will perform:
| Operation | Description |
|---|---|
Scan: TableName |
Full table scan — reads all rows |
IndexSeek: IndexName |
Uses index for exact match lookup |
IndexRangeScan: IndexName |
Uses index for range of values |
Filter: condition |
Applies WHERE condition after reading |
Sort: columns |
Sorts results (for ORDER BY) |
Aggregate: function |
Computes COUNT, SUM, etc. |
Join: type |
Joins two tables (INNER, LEFT, etc.) |
Limit: n |
Returns only first n rows |
Index Usage Analysis
Use EXPLAIN to verify your indexes are being used:
// Without index
engine.Execute("EXPLAIN SELECT * FROM Orders WHERE CustomerId = 123");
// Output: Scan: Orders, Filter: CustomerId = 123
// Estimated rows: 10000
// Create index
engine.Execute("CREATE INDEX IX_Orders_CustomerId ON Orders(CustomerId)");
// With index
engine.Execute("EXPLAIN SELECT * FROM Orders WHERE CustomerId = 123");
// Output: IndexSeek: IX_Orders_CustomerId
// Estimated rows: 15Analyzing Complex Queries
For JOINs and subqueries, EXPLAIN shows the full operation tree:
var sql = @"
EXPLAIN
SELECT o.Id, c.Name, o.Total
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE o.OrderDate > '2024-01-01'
ORDER BY o.Total DESC
LIMIT 10";
using var result = engine.Execute(sql);
while (result.Read())
{
Console.WriteLine(result.CurrentRow["Plan"].AsString());
}Output:
Limit: 10
Sort: Total DESC
Join: INNER
Left: IndexRangeScan: IX_Orders_OrderDate (> '2024-01-01')
Right: IndexSeek: PK_CustomersPerformance Tips from EXPLAIN
Look for full table scans on large tables. If you see Scan: TableName with many estimated rows, consider adding an index.
Check estimated row counts. Large estimated rows at early stages can slow down the entire query.
Verify JOIN order. The engine processes joins left-to-right. Smaller result sets should be on the left when possible.
// Helper method to analyze query performance
public void AnalyzeQuery(string sql)
{
using var result = engine.Execute($"EXPLAIN {sql}");
Console.WriteLine("Execution Plan:");
while (result.Read())
{
var plan = result.CurrentRow["Plan"].AsString();
// Highlight potential issues
if (plan.Contains("Scan:") && !plan.Contains("Estimated rows: 0"))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠ {plan}");
Console.ResetColor();
}
else
{
Console.WriteLine($" {plan}");
}
}
}Schema Introspection
The SQL Engine provides methods to inspect database schema programmatically, without executing SQL queries.
Getting Table Information
// Get table metadata
DefinitionTable? table = engine.GetTable("Users");
if (table != null)
{
Console.WriteLine($"Table: {table.Name}");
Console.WriteLine($"Columns: {table.Columns.Count}");
foreach (var column in table.Columns)
{
Console.WriteLine($" {column.Name}: {column.Type}");
Console.WriteLine($" Nullable: {column.IsNullable}");
Console.WriteLine($" Primary Key: {column.IsPrimaryKey}");
Console.WriteLine($" Default: {column.DefaultValue ?? "none"}");
}
}Table Row Count
Get the row count without scanning the table:
// O(1) operation - uses cached metadata
long rowCount = engine.GetTableRowCount("Users");
Console.WriteLine($"Users table has {rowCount} rows");
// Compare with SQL COUNT (scans table)
// This is slower for large tables
var countResult = engine.ExecuteScalar("SELECT COUNT(*) FROM Users");The GetTableRowCount method returns the cached count maintained by the engine. It's much faster than SELECT COUNT(*) but may be slightly out of date during concurrent writes.
Index Information
// Get a specific index
DefinitionIndex? index = engine.GetIndex("IX_Users_Email");
if (index != null)
{
Console.WriteLine($"Index: {index.Name}");
Console.WriteLine($"Table: {index.TableName}");
Console.WriteLine($"Unique: {index.IsUnique}");
Console.WriteLine($"Columns: {string.Join(", ", index.Columns)}");
}
// Get all indexes for a table
foreach (var idx in engine.GetTableIndexes("Orders"))
{
Console.WriteLine($" {idx.Name} on ({string.Join(", ", idx.Columns)})");
}Listing All Tables
// Using INFORMATION_SCHEMA (SQL standard)
var tables = engine.Query(@"
SELECT TABLE_NAME, TABLE_TYPE
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'public'");
foreach (var row in tables)
{
Console.WriteLine($"{row["TABLE_NAME"].AsString()} ({row["TABLE_TYPE"].AsString()})");
}Column Details via INFORMATION_SCHEMA
var columns = engine.Query(@"
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Users'
ORDER BY ORDINAL_POSITION");
foreach (var col in columns)
{
var nullable = col["IS_NULLABLE"].AsString() == "YES" ? "NULL" : "NOT NULL";
Console.WriteLine($" {col["COLUMN_NAME"].AsString()}: {col["DATA_TYPE"].AsString()} {nullable}");
}Low-Level Table Operations
For advanced scenarios, the SQL Engine provides direct access to table data through iterators. These bypass SQL parsing entirely for maximum performance.
Table Scan Iterator
Iterate through all rows in a table:
// Create a table scan iterator
using var iterator = engine.CreateTableScan("Users");
while (iterator.MoveNext())
{
WitSqlRow row = iterator.Current;
Console.WriteLine($"User: {row["Name"].AsString()}");
}Table scans are useful when:
- You need to process every row
- No index exists for your filter condition
- You're implementing custom filtering logic
Index Seek
Look up rows by exact index match:
// Exact match on indexed column
// Equivalent to: SELECT * FROM Users WHERE Email = 'alice@test.com'
var keyValues = new WitSqlValue[] { WitSqlValue.FromText("alice@test.com") };
using var iterator = engine.CreateIndexSeek("Users", "IX_Users_Email", keyValues);
while (iterator.MoveNext())
{
var row = iterator.Current;
Console.WriteLine($"Found: {row["Name"].AsString()}");
}Index seek is the fastest lookup method when you know the exact key value.
Index Range Scan
Scan a range of index values:
// Range scan: Orders where Total between 100 and 500
// Equivalent to: SELECT * FROM Orders WHERE Total >= 100 AND Total <= 500
var startKey = WitSqlValue.FromDecimal(100m);
var endKey = WitSqlValue.FromDecimal(500m);
using var iterator = engine.CreateIndexRangeScan(
tableName: "Orders",
indexName: "IX_Orders_Total",
startKey: startKey,
startInclusive: true, // >= 100
endKey: endKey,
endInclusive: true); // <= 500
while (iterator.MoveNext())
{
var order = iterator.Current;
Console.WriteLine($"Order {order["Id"].AsInt64()}: ${order["Total"].AsDecimal()}");
}Direct Row Access by ID
Fetch a single row by its internal row ID (fastest possible lookup):
// If you know the row ID (e.g., from a previous query or index lookup)
long rowId = 12345;
WitSqlRow? row = engine.GetRowById("Users", rowId);
if (row != null)
{
Console.WriteLine($"User: {row["Name"].AsString()}");
}Row IDs are internal identifiers and may change after table modifications. Use primary keys for stable references.
Performance Comparison
For looking up a single user by email:
| Method | Time | Use Case |
|---|---|---|
SELECT ... WHERE Email = @email |
~50µs | General use |
CreateIndexSeek() |
~10µs | Hot paths, bypassing SQL |
GetRowById() |
~2µs | When row ID is known |
The low-level methods skip SQL parsing and planning, providing significant speedup for simple operations in performance-critical code.
Practical Example: Custom Index Scan
Implement a custom filtered scan that uses an index:
public IEnumerable<WitSqlRow> GetOrdersInRange(decimal minTotal, decimal maxTotal, Func<WitSqlRow, bool> additionalFilter)
{
using var iterator = engine.CreateIndexRangeScan(
"Orders",
"IX_Orders_Total",
WitSqlValue.FromDecimal(minTotal), true,
WitSqlValue.FromDecimal(maxTotal), true);
while (iterator.MoveNext())
{
if (additionalFilter(iterator.Current))
{
yield return iterator.Current;
}
}
}
// Usage
var orders = GetOrdersInRange(100m, 500m,
row => row["Status"].AsString() == "Completed");Query Plan Cache
The SQL Engine automatically caches parsed query plans for identical SQL strings, improving performance for repeated queries.
How Caching Works
When you execute SQL:
- Engine checks if this exact SQL string has been parsed before
- If cached, reuses the parsed AST (Abstract Syntax Tree)
- If not cached, parses and caches for future use
This means the second execution of the same SQL skips parsing entirely:
// First call: parses SQL, caches plan
engine.Query("SELECT * FROM Users WHERE IsActive = 1");
// Second call: reuses cached plan (faster)
engine.Query("SELECT * FROM Users WHERE IsActive = 1");Parameters and Caching
Use parameters to maximize cache hits:
// BAD: Different SQL strings, no cache benefit
engine.Query("SELECT * FROM Users WHERE Id = 1");
engine.Query("SELECT * FROM Users WHERE Id = 2");
engine.Query("SELECT * FROM Users WHERE Id = 3");
// GOOD: Same SQL string, parameters vary, cache hit every time
engine.Query("SELECT * FROM Users WHERE Id = @id", new Dictionary<string, object?> { ["id"] = 1 });
engine.Query("SELECT * FROM Users WHERE Id = @id", new Dictionary<string, object?> { ["id"] = 2 });
engine.Query("SELECT * FROM Users WHERE Id = @id", new Dictionary<string, object?> { ["id"] = 3 });Prepared Statements vs Query Cache
Both improve performance, but differently:
| Feature | Query Plan Cache | Prepared Statement |
|---|---|---|
| Caches | Parsed AST | Parsed AST + execution context |
| Scope | Global (all queries) | Single statement instance |
| Best for | Ad-hoc repeated queries | Known hot paths, batches |
| Memory | Automatic LRU eviction | Manual disposal |
Use prepared statements for known high-frequency queries. The query cache handles the rest automatically.
Error Handling
SQL errors throw exceptions with detailed information.
Exception Types
try
{
engine.Execute("SELECT * FROM NonExistentTable");
}
catch (WitSqlException ex)
{
Console.WriteLine($"SQL Error: {ex.Message}");
// Output: SQL Error: Table 'NonExistentTable' does not exist
}Common Exception Types
| Exception | Cause |
|---|---|
WitSqlException |
General SQL execution error (syntax, missing table, constraint violation) |
InvalidOperationException |
Invalid engine state (disposed, transaction in wrong state) |
ArgumentException |
Invalid parameters (null SQL, invalid parameter name) |
OperationCanceledException |
Query was cancelled via CancellationToken |
TimeoutException |
Query exceeded specified timeout |
Handling Specific Errors
try
{
engine.Execute("INSERT INTO Users (Email) VALUES (@email)",
new Dictionary<string, object?> { ["email"] = "duplicate@test.com" });
}
catch (WitSqlException ex) when (ex.Message.Contains("UNIQUE constraint"))
{
Console.WriteLine("Email already exists");
}
catch (WitSqlException ex) when (ex.Message.Contains("NOT NULL constraint"))
{
Console.WriteLine("Required field is missing");
}
catch (WitSqlException ex)
{
Console.WriteLine($"Database error: {ex.Message}");
}Timeout Handling
try
{
using var result = engine.Execute(
"SELECT * FROM VeryLargeTable WHERE ComplexCondition(Column)",
parameters: null,
timeout: TimeSpan.FromSeconds(5));
// Process results...
}
catch (TimeoutException)
{
Console.WriteLine("Query took too long and was aborted");
}Checking Results for Errors
Some operations indicate issues through results rather than exceptions:
using var result = engine.Execute("INSERT INTO Users (Email) VALUES ('duplicate@test.com')");
if (result.RowsAffected == 0)
{
// Insert might have failed due to constraint (with ON CONFLICT DO NOTHING)
Console.WriteLine("No rows inserted");
}Resource Management
The SQL Engine and its results implement IDisposable. Proper disposal prevents resource leaks.
Disposing the Engine
// Option 1: using statement
using var engine = new WitSqlEngine(database, ownsStore: true);
// Engine disposed at end of scope
// Option 2: Manual disposal
var engine = new WitSqlEngine(database, ownsStore: true);
try
{
// Use engine...
}
finally
{
engine.Dispose();
}Disposing Results
Always dispose query results:
// GOOD: using statement
using var result = engine.Execute("SELECT * FROM Users");
while (result.Read()) { /* ... */ }
// GOOD: Manual disposal
var result = engine.Execute("SELECT * FROM Users");
try
{
while (result.Read()) { /* ... */ }
}
finally
{
result.Dispose();
}
// Convenience methods that return collections handle disposal internally
List<WitSqlRow> rows = engine.Query("SELECT * FROM Users"); // No using neededDisposing Prepared Statements
using var stmt = engine.Prepare("SELECT * FROM Users WHERE Id = @id");
// Statement disposed at end of scopeDisposing Iterators
Low-level iterators must be disposed:
using var iterator = engine.CreateTableScan("Users");
while (iterator.MoveNext())
{
// Process rows...
}
// Iterator disposed, resources releasedThread Safety
The SQL Engine is not thread-safe. Each thread should have its own engine instance, or you must synchronize access.
Recommended Pattern for Multi-threaded Applications
// Shared database, engine per thread
var database = new WitDatabaseBuilder()
.WithFilePath("shared.witdb")
.WithBTree()
.WithTransactions()
.Build();
Parallel.For(0, 10, i =>
{
// Each thread gets its own engine
using var engine = new WitSqlEngine(database, ownsStore: false);
engine.Execute("INSERT INTO Logs (Message) VALUES (@msg)",
new Dictionary<string, object?> { ["msg"] = $"Thread {i}" });
});
database.Dispose(); // Dispose database after all enginesUsing with Dependency Injection
For web applications, create engines per request:
services.AddSingleton<WitDatabase>(sp =>
{
return new WitDatabaseBuilder()
.WithFilePath("app.witdb")
.WithBTree()
.WithTransactions()
.Build();
});
services.AddScoped<WitSqlEngine>(sp =>
{
var database = sp.GetRequiredService<WitDatabase>();
return new WitSqlEngine(database, ownsStore: false);
});Thread-Safe Wrapper (If Needed)
For scenarios where you must share an engine:
public class ThreadSafeEngine : IDisposable
{
private readonly WitSqlEngine _engine;
private readonly Lock _lock = new();
public ThreadSafeEngine(WitDatabase database)
{
_engine = new WitSqlEngine(database, ownsStore: false);
}
public List<WitSqlRow> Query(string sql, IDictionary<string, object?>? parameters = null)
{
lock (_lock)
{
return _engine.Query(sql, parameters);
}
}
public int ExecuteNonQuery(string sql, IDictionary<string, object?>? parameters = null)
{
lock (_lock)
{
return _engine.ExecuteNonQuery(sql, parameters);
}
}
public void Dispose() => _engine.Dispose();
}Note: This serializes all access, which may reduce throughput. Per-thread engines are usually better.
Quick Reference
Engine Creation
// File-based
var db = new WitDatabaseBuilder().WithFilePath("app.witdb").WithBTree().WithTransactions().Build();
var engine = new WitSqlEngine(db, ownsStore: true);
// In-memory
var db = WitDatabase.CreateInMemory();
var engine = new WitSqlEngine(db, ownsStore: true);Query Methods
| Method | Returns | Use Case |
|---|---|---|
Execute(sql, params?, timeout?, token?) |
WitSqlResult |
Any SQL, full control |
Query(sql, params?) |
List<WitSqlRow> |
SELECT, get all rows |
QueryFirstOrDefault(sql, params?) |
WitSqlRow? |
SELECT, first row or null |
ExecuteScalar(sql, params?) |
WitSqlValue |
SELECT, single value |
ExecuteNonQuery(sql, params?) |
int |
INSERT/UPDATE/DELETE, row count |
Prepare(sql) |
WitSqlEngineStatement |
Reusable statement |
Schema Introspection
| Method | Returns | Description |
|---|---|---|
GetTable(name) |
DefinitionTable? |
Table metadata |
GetTableRowCount(name) |
long |
Cached row count (O(1)) |
GetIndex(name) |
DefinitionIndex? |
Index metadata |
GetTableIndexes(tableName) |
IEnumerable<DefinitionIndex> |
All indexes for table |
Low-Level Access
| Method | Returns | Description |
|---|---|---|
CreateTableScan(table) |
IResultIterator |
Full table scan |
CreateIndexSeek(table, index, keys) |
IResultIterator |
Exact index match |
CreateIndexRangeScan(table, index, start, end) |
IResultIterator |
Index range scan |
GetRowById(table, rowId) |
WitSqlRow? |
Direct row fetch |
Prepared Statement Methods
| Method | Description |
|---|---|
SetParameter(name, value) |
Set single parameter |
SetParameters(dict) |
Set multiple parameters |
ClearParameters() |
Clear all parameters |
Execute(token?) |
Execute with current parameters |
Execute(params, token?) |
Execute with provided parameters |
ExecuteBatch(paramSets, token?) |
Execute multiple times |
Transaction SQL
BEGIN TRANSACTION
COMMIT
ROLLBACK
SAVEPOINT name
ROLLBACK TO name
RELEASE SAVEPOINT name
SET TRANSACTION ISOLATION LEVEL levelType Conversions
row["Column"].AsInt64() // long
row["Column"].AsInt64OrNull() // long?
row["Column"].AsDouble() // double
row["Column"].AsDecimal() // decimal
row["Column"].AsString() // string
row["Column"].AsStringOrNull() // string?
row["Column"].AsBoolean() // bool
row["Column"].AsDateTime() // DateTime
row["Column"].AsGuid() // Guid
row["Column"].AsBytes() // byte[]
row["Column"].IsNull // boolEXPLAIN Output
EXPLAIN SELECT * FROM Orders WHERE CustomerId = 123