SoloDB Documentation
SoloDB is an embedded document database for .NET that stores your objects as JSON documents inside SQLite. It gives you the flexibility of a NoSQL database with the reliability of SQLite, all without running a separate server.
When to Use SoloDB
SoloDB is ideal for:
- Desktop applications that need local data persistence
- Mobile apps via .NET MAUI
- Web applications that don't need distributed databases
- Prototyping when you want to store objects without defining schemas
- Embedded systems where you need a reliable, self-contained database
Key Characteristics
- Documents stored as SQLite JSONB - binary JSON for efficient storage and querying
- LINQ support with compile-time type safety
- ACID transactions inherited from SQLite
- Thread-safe with built-in connection pooling
- Zero configuration - just create the database and start using it
Installation
Install from NuGet:
dotnet add package SoloDBRequirements
- .NET Standard 2.0 or 2.1 (compatible with .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5+)
- Works on Windows, Linux, and macOS
First Steps
Let's store and retrieve some data. First, define a class for your data:
public class User
{
public long Id { get; set; } // This becomes the primary key
public string Name { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
}Now create a database, get a collection, and perform operations:
using SoloDatabase;
// Create or open a database file
using var db = new SoloDB("myapp.db");
// Get a typed collection - creates it automatically if it doesn't exist
var users = db.GetCollection<User>();
// Insert a document
var user = new User
{
Name = "Alice",
Email = "alice@example.com",
CreatedAt = DateTime.UtcNow
};
users.Insert(user);
// user.Id is now set to the auto-generated value (e.g., 1)
// Query with LINQ
var found = users.First(u => u.Email == "alice@example.com");
// Update
found.Name = "Alice Smith";
users.Update(found);
// Delete
users.Delete(found.Id);Note: The using statement ensures the database connection is properly closed when done. In long-running applications, you typically create one SoloDB instance and reuse it throughout the application's lifetime.
Identity
If a long Id is enough for your document type, use it and let SoloDB fill it automatically. You can also store plain types such as strings and integers without an Id property. If you want a different identifier type, use the [SoloId] attribute with a custom ID generator — see ID Generation for the full rules.
Relations at a Glance
SoloDB supports typed references between documents. Use DBRef<T> for a single reference and DBRefMany<T> for a collection. Relations require long Id on both the owner and target types.
public class Author
{
public long Id { get; set; }
public string Name { get; set; }
}
public class Book
{
public long Id { get; set; }
public string Title { get; set; }
public DBRef<Author> Author { get; set; }
}
var authors = db.GetCollection<Author>();
var books = db.GetCollection<Book>();
var authorId = authors.Insert(new Author { Name = "Hedy Lamarr" });
books.Insert(new Book
{
Title = "Frequency Hopping",
Author = DBRef<Author>.To(authorId)
});
// Query through the reference
var hedyBooks = books.Where(b => b.Author.Value.Name == "Hedy Lamarr").ToList();Since 1.2.0, SoloDB also supports DBRefMany filtering, aggregation, GroupJoin, and nested relation queries. See Relation Queries.
How Data is Stored
Understanding how SoloDB stores your data helps you design better models and write efficient queries.
The Storage Model
Each collection is a SQLite table with three columns:
Id- INTEGER PRIMARY KEY (auto-incremented by default)Value- JSONB containing your serialized objectMetadata- JSONB used internally by SoloDB (relation version tracking)
When you insert this object:
var user = new User { Name = "Alice", Email = "alice@example.com" };
users.Insert(user);SoloDB creates a row like this:
// Conceptually:
// Id: 1
// Value: {"Name":"Alice","Email":"alice@example.com","CreatedAt":"..."}JSONB Format
SoloDB uses SQLite's native JSONB (binary JSON) format, which means:
- Queries can use SQLite's JSON functions for efficient filtering
- No JSON parsing overhead on every read - binary format is faster
- You can even query the data using raw SQL with JSON functions
Serialization Rules Since v1.0.0
Understanding what gets serialized is crucial for designing your data models correctly.
Classes: Only Public Properties
For classes, SoloDB serializes public instance properties with getters. Fields are ignored.
public class Example
{
// SERIALIZED - public property with getter
public string Name { get; set; }
// SERIALIZED - public property (getter required, setter for deserialization)
public int Age { get; set; }
// NOT SERIALIZED - field (even if public)
public string PublicField;
// NOT SERIALIZED - private property
private string Secret { get; set; }
// NOT SERIALIZED - internal property
internal string Internal { get; set; }
}Important: For deserialization, properties need a public setter. Read-only properties can be serialized but won't be populated when loading from the database.
Structs: Fields and Properties
Structs behave differently - both public fields AND public properties are serialized:
public struct Point
{
public int X; // SERIALIZED - public field on struct
public int Y; // SERIALIZED - public field on struct
public double Distance { get; set; } // SERIALIZED - public property
}Supported Types
SoloDB's built-in serializer handles these types natively:
| Primitives | int, long, float, double, decimal, bool, char, byte, etc. |
| Enums | Stored as their underlying integer value, not as strings |
| Strings | string (null-safe) |
| Date/Time | DateTime, DateTimeOffset, TimeSpan |
| GUIDs | Guid |
| Collections | Arrays, List<T>, Dictionary<K,V>, HashSet<T>, Queue<T>, Stack<T> |
| Nullable | Nullable<T> (e.g., int?, DateTime?) |
| Tuples | ValueTuple, Tuple |
| F# Types | F# records, discriminated unions, F# lists |
| Binary | byte[] (stored as Base64) |
| Nested Objects | Any class/struct following these rules |
Nested Objects
Objects can contain other objects to any depth:
public class Order
{
public long Id { get; set; }
public Customer Customer { get; set; } // Nested object
public List<OrderItem> Items { get; set; } // List of objects
public Address ShippingAddress { get; set; } // Another nested object
}
public class OrderItem
{
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}C# Language Features
init-only setters (C# 9+) — supported. Properties round-trip correctly.requiredmodifier (C# 11+) — supported as the normal compiler feature. SoloDB does not add runtime enforcement beyond what C# provides.- Enums — stored as their underlying integer value, not as strings.
What to Avoid
- Circular references - will cause stack overflow
- Very deep nesting - impacts performance and query complexity
- Storing huge binary data - use the FileSystem API instead
Custom JSON Serializer
SoloDB uses its own high-performance JSON serializer instead of Newtonsoft.Json or System.Text.Json. The serializer is designed specifically for document database use cases:
- Generic type caching - Serializers are generated once per type and cached for subsequent use
- Polymorphic support - Automatically adds
$typediscriminator for non-sealed types when needed - F# native support - Records, discriminated unions, and F# lists are handled natively
- No external dependencies - Self-contained implementation with no JSON library dependencies
The serializer converts objects to an internal JsonValue representation which is then stored as SQLite JSONB. Deserialization reads the JSONB and maps it back to your types using the same rules described above.
Schema Evolution for Plain Document Fields
SoloDB does not need a migration step for every document-class edit, but not every change means the same thing when older rows are read back.
| Change | What happens |
| Add a property | Older documents load with that property at its CLR default value. |
| Remove a property | The old JSON field is ignored; the remaining mapped fields still load normally. |
| Rename a property | No automatic remap happens. Old JSON stays under the old name until you migrate it. |
| Change a property type | You need to migrate the stored data. For example, changing int to string does not convert old values automatically. |
Old values stay in the database and are not cleaned up automatically. New properties on existing documents are initialized to their CLR default. If you rename or change a property's type, you need to migrate the stored data yourself.
ID Generation Since v1.0.0
Every document needs a unique identifier. SoloDB provides flexible options for ID handling.
Default: Auto-Increment Long
The simplest approach - name a property Id with type long:
public class Product
{
public long Id { get; set; } // Auto-detected as primary key
public string Name { get; set; }
}
var products = db.GetCollection<Product>();
var product = new Product { Name = "Widget" };
products.Insert(product);
// product.Id is now 1, 2, 3, etc.Custom ID with [SoloId] Attribute
For other ID types or custom generation logic, use the [SoloId] attribute with a custom generator:
using SoloDatabase.Attributes;
// Define a custom ID generator that produces string IDs from GUIDs
public class StringGuidIdGenerator : IIdGenerator<Document>
{
public object GenerateId(ISoloDBCollection<Document> collection, Document item)
{
return Guid.NewGuid().ToString("N"); // Returns string like "a1b2c3d4..."
}
public bool IsEmpty(object id)
{
return string.IsNullOrEmpty(id as string);
}
}
// Use it in your model
public class Document
{
[SoloId(typeof(StringGuidIdGenerator))]
public string Id { get; set; } // String ID, not Guid
public string Title { get; set; }
public string Content { get; set; }
}Supported ID Types
long | Default, auto-incremented by SQLite |
int | Auto-incremented (cast from SQLite's int64) |
Guid | Requires a generator (e.g., one that calls Guid.NewGuid()) |
string | Must be provided by your generator or set before insert |
Note: For long and int ID types without a custom generator, SQLite handles auto-incrementing automatically. You don't need to implement a generator for these common cases.
Custom Guid ID Generator Example
Here's a simple generator for Guid IDs:
public class GuidIdGenerator : IIdGenerator<MyDocument>
{
public object GenerateId(ISoloDBCollection<MyDocument> collection, MyDocument item)
{
return Guid.NewGuid();
}
public bool IsEmpty(object id) => id is Guid g && g == Guid.Empty;
}Working with Collections Since v1.0.0
Collections are containers for your documents, similar to tables in SQL databases.
Getting a Collection
// Typed collection - name derived from type (recommended)
var users = db.GetCollection<User>(); // Collection name: "User"
// Custom name - useful for multiple collections of same type
var activeUsers = db.GetCollection<User>("ActiveUsers");
var archivedUsers = db.GetCollection<User>("ArchivedUsers");
// Untyped collection for dynamic scenarios
var untypedCollection = db.GetUntypedCollection("MyData");Collection Lifecycle
- Collections are created automatically when first accessed
- The underlying SQLite table is created with the proper schema
- Indexes defined via attributes are created on first access
Reserved names: Collection names starting with SoloDB are reserved for internal use and will throw an ArgumentException. For example, "SoloDBUsers" is not allowed, but "MyUsers" or "UsersSoloDB" are fine.
// Check if a collection exists
bool exists = db.CollectionExists<User>();
bool existsByName = db.CollectionExists("User");
// Drop a collection (deletes all data!)
db.DropCollection<User>();
db.DropCollection("ArchivedUsers");CRUD Operations Since v1.0.0
Insert
var users = db.GetCollection<User>();
// Single insert - returns the generated ID
var user = new User { Name = "Alice", Email = "alice@example.com" };
long id = users.Insert(user);
// user.Id is also set to the same value
// Batch insert - much faster for multiple items
var newUsers = new List<User>
{
new User { Name = "Bob", Email = "bob@example.com" },
new User { Name = "Charlie", Email = "charlie@example.com" }
};
IList<long> ids = users.InsertBatch(newUsers);Insert or Replace (Upsert)
When you have unique indexes, you can upsert based on those constraints:
// If a user with this email exists (assuming unique index), replace it
var user = new User { Name = "Alice Updated", Email = "alice@example.com" };
users.InsertOrReplace(user);
// Batch version
users.InsertOrReplaceBatch(manyUsers);Read
// By ID (throws KeyNotFoundException if not found)
User user = users.GetById(1);
// TryGetById returns FSharpOption<T> in C#
var userOption = users.TryGetById(1);
if (FSharpOption<User>.get_IsSome(userOption))
{
User existingUser = userOption.Value;
}
// By custom ID type
Document doc = documents.GetById<string>("doc-abc-123");
// All documents as a list
var allUsers = users.ToList();Update
// Full document update
var user = users.GetById(1);
user.Name = "New Name";
user.Email = "new@email.com";
users.Update(user); // Replaces entire document
// Replace matching documents
users.ReplaceOne(u => u.Email == "old@example.com", newUserData);
users.ReplaceMany(u => u.Status == "pending", templateUser);Note: Methods ending in One (like ReplaceOne, DeleteOne) affect only one document. If multiple documents match the filter, which one is affected is determined by SQLite's internal ordering and may appear random. Use these methods only when you expect exactly one match, or when you don't care which matching document is affected.
Partial Updates with UpdateMany
For efficient partial updates without loading the full document. This is significantly faster than loading documents with GetById, modifying them, and calling Update, because it executes a single SQL statement instead of multiple round-trips.
The update expressions use .Set(...) and .Append(...) — these are SoloDB helpers that translate to SQL, not regular methods on your properties.
// Set a single field
int count = users.UpdateMany(
u => u.Id <= 10, // Filter
u => u.IsActive.Set(true) // Update action
);
// Set multiple fields at once
users.UpdateMany(
u => u.Status == "pending",
u => u.Status.Set("approved"),
u => u.ApprovedAt.Set(DateTime.UtcNow),
u => u.ApprovedBy.Set("admin")
);
// Append to a collection property
users.UpdateMany(
u => u.Id == userId,
u => u.Tags.Append("verified")
);Delete
// By ID - returns count of deleted (0 or 1)
int deleted = users.Delete(1);
// By custom ID
documents.Delete<string>("doc-abc-123");
// By predicate
users.DeleteOne(u => u.Email == "old@example.com"); // First match only
users.DeleteMany(u => u.IsActive == false); // All matchesQuerying with LINQ Since v1.0.0
SoloDB collections implement IQueryable<T>. Queries are translated to SQL and executed on SQLite. A small number of exotic LINQ methods (Aggregate, Zip, Reverse, Append, Prepend, SequenceEqual) are not supported.
Filtering
// Where clause
var activeUsers = users.Where(u => u.IsActive).ToList();
// Multiple conditions
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
var results = users.Where(u =>
u.IsActive &&
u.CreatedAt > thirtyDaysAgo &&
u.Email.Contains("@company.com")
).ToList();Single Item Queries
// First match (throws if none)
var first = users.First(u => u.Email == "admin@example.com");
// First or default (returns null if none)
var admin = users.FirstOrDefault(u => u.Role == "Admin");
// Single (throws if not exactly one)
var unique = users.Single(u => u.Username == "johndoe");
// Check existence
bool hasAdmins = users.Any(u => u.Role == "Admin");
bool allActive = users.All(u => u.IsActive);Ordering and Pagination
// Order by
var sorted = users
.OrderBy(u => u.Name)
.ThenByDescending(u => u.CreatedAt)
.ToList();
// Pagination
int page = 2;
int pageSize = 20;
var pageResults = users
.OrderBy(u => u.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();Projections
// Select specific properties
var emails = users.Select(u => u.Email).ToList();
// Project to anonymous type
var summaries = users.Select(u => new
{
u.Name,
u.Email,
DaysSinceCreated = (DateTime.UtcNow - u.CreatedAt).Days
}).ToList();
// Project to DTO
var dtos = users.Select(u => new UserDto
{
FullName = u.Name,
ContactEmail = u.Email
}).ToList();Aggregates
int totalUsers = users.Count();
int activeCount = users.Count(u => u.IsActive);
long total = users.LongCount();
// Note: Min, Max, Sum, Average are supported on numeric projections
var maxId = users.Max(u => u.Id);
var avgPrice = products.Average(p => p.Price);SQLite computes AVG using IEEE 64-bit floating-point arithmetic. For decimal values, the result is converted back from double, so there may be small precision differences compared to .NET decimal arithmetic.
String Operations
// Contains, StartsWith, EndsWith
var results = users.Where(u =>
u.Name.Contains("john") ||
u.Email.StartsWith("admin") ||
u.Email.EndsWith("@company.com")
).ToList();
// SQL LIKE pattern (extension method — import SoloDatabase namespace)
var pattern = users.Where(u => u.Name.Like("J%n")).ToList();StartsWith can use an index because SoloDB translates it to a range predicate that SQLite can search efficiently. EndsWith and Contains still require a scan. Like(...) is available after using SoloDatabase;.
Array/Collection Queries
// Query nested arrays
var tagged = users.Where(u => u.Tags.Contains("premium")).ToList();
// Check if any element matches
var oneWeekAgo = DateTime.UtcNow.AddDays(-7);
var withRecentOrders = users.Where(u =>
u.Orders.Any(o => o.Date > oneWeekAgo)
).ToList();Indexing Since v1.0.0
Indexes dramatically improve query performance for filtered and sorted operations. Without an index, SoloDB must scan every document.
Attribute-Based Indexes
The easiest way - add [Indexed] to properties you frequently query:
using SoloDatabase.Attributes;
public class Product
{
public long Id { get; set; } // Always indexed (primary key)
[Indexed(unique: true)] // Unique index - no duplicates allowed
public string SKU { get; set; }
[Indexed] // Non-unique index
public string Category { get; set; }
[Indexed]
public decimal Price { get; set; }
public string Description { get; set; } // Not indexed
}Indexes are automatically created when the collection is first accessed.
When to Index
- DO index: Properties used in
Whereclauses,OrderBy, and unique constraints - DON'T index: Properties rarely queried, or only used in
Selectprojections - Consider trade-offs: Indexes speed up reads but slow down writes slightly, and increase the database file size on disk/memory
Programmatic Indexes
var products = db.GetCollection<Product>();
// Add an index on a property not covered by attributes
products.EnsureIndex(p => p.Description);
// Remove an index
products.DropIndexIfExists(p => p.Description);
// Ensure all attribute-defined indexes exist
products.EnsureAddedAttributeIndexes();Note: If you add new [Indexed] attributes to your model classes after the database already exists, the indexes won't be created automatically until you call EnsureAddedAttributeIndexes(). Indexes are only auto-created on first collection access. If you remove an [Indexed] attribute later, SoloDB does not drop the old index automatically; remove it explicitly with DropIndexIfExists(...) if you no longer want it.
Supported Index Expressions
EnsureIndex and EnsureUniqueAndIndex accept expressions that reference entity properties directly:
// Direct property
products.EnsureIndex(p => p.Category);
// Composite index (C# expression trees require the ValueTuple constructor form)
products.EnsureIndex(p => new ValueTuple<string, decimal>(p.Category, p.Price));
// DBRef.Id navigation (indexes the foreign key)
orders.EnsureIndex(o => o.Author.Id);Note: In C#, composite index expressions must use the ValueTuple constructor form because expression trees do not accept tuple literal syntax.
Unsupported Index Expressions
The following expressions are rejected at call time with a descriptive error message:
x => x.Id | Entity Id is always indexed automatically |
x => x.Items.Count | Relation expressions that resolve through link tables cannot be indexed |
x => x.Tag.Value.Name | DBRef.Value navigation requires a JOIN and cannot be indexed |
x => x.Name + "suffix" | Expressions containing variables are not allowed |
x => 42 | Constant expressions must reference the entity parameter |
Unique Constraint Violations
Inserting a duplicate value for a unique index throws SqliteException:
try
{
products.Insert(new Product { SKU = "EXISTING-SKU" });
}
catch (Microsoft.Data.Sqlite.SqliteException ex)
when (ex.Message.Contains("UNIQUE"))
{
Console.WriteLine("SKU already exists!");
}Relations Since v1.1
SoloDB supports typed references between documents using DBRef<T> for single references and DBRefMany<T> for collections. DBRef<T> references are serialized as the target id in the owner document and backed by a link table. DBRefMany<T> references are stored only in link tables.
When you query documents with references, SoloDB loads everything — all referenced entities are fetched automatically. You can use Include and Exclude to control which references load if you need to.
Relation writes participate in the same transaction model as document writes: root scopes use BEGIN IMMEDIATE, nested scopes use SAVEPOINT, and inner rollback does not abort the outer transaction.
Starting with 1.2.0, relation LINQ queries flow through an internal typed query engine that translates them to SQLite SQL. This enables the broader DBRefMany, GroupJoin, and nested relation query support in this release.
Single Reference (DBRef) Since v1.1
Use DBRef<T> to reference one document from another. A single entity can have multiple DBRef properties pointing to the same target type:
public class Person
{
public long Id { get; set; }
public string Name { get; set; }
}
public class Loan
{
public long Id { get; set; }
public decimal Amount { get; set; }
// Unlink only — deleting a loan does not delete the referenced persons
[SoloRef(OnOwnerDelete = DeletePolicy.Unlink)]
public DBRef<Person> Borrower { get; set; }
[SoloRef(OnOwnerDelete = DeletePolicy.Unlink)]
public DBRef<Person> Guarantor { get; set; }
}
var people = db.GetCollection<Person>();
var loans = db.GetCollection<Loan>();
var aliceId = people.Insert(new Person { Name = "Alice" });
var bobId = people.Insert(new Person { Name = "Bob" });
// Reference existing persons by Id
loans.Insert(new Loan
{
Amount = 5000,
Borrower = DBRef<Person>.To(aliceId),
Guarantor = DBRef<Person>.To(bobId)
});
// Or cascade-insert a new person automatically
loans.Insert(new Loan
{
Amount = 3000,
Borrower = DBRef<Person>.To(aliceId),
Guarantor = DBRef<Person>.From(new Person { Name = "Charlie" })
});
// Query loans by borrower — referenced entity is loaded automatically
var aliceLoans = loans.Where(l => l.Borrower.Id == aliceId).ToList();
Console.WriteLine(aliceLoans[0].Borrower.Value.Name); // "Alice"
Console.WriteLine(aliceLoans[0].Guarantor.Value.Name); // "Bob"
// Query through the referenced entity's properties
var charlieGuaranteed = loans.Where(l =>
l.Guarantor.Value.Name == "Charlie"
).ToList();
Console.WriteLine(charlieGuaranteed[0].Amount); // 3000HasValue vs IsLoaded
HasValue and IsLoaded are independent properties:
HasValue—trueif a reference exists (Id != 0). This is about whether a target is referenced.IsLoaded—trueif theValuehas been loaded by the query. This is about whether the target entity data is available in memory.
DBRef<T>.None | HasValue = false, IsLoaded = false |
DBRef<T>.To(id) | HasValue = true, IsLoaded = false |
| After query loads entity | HasValue = true, IsLoaded = true |
State Transition Examples
Example 1: Before Loading the Target Entity
// DBRef.To(id) sets HasValue=true but IsLoaded=false
var borrowerId = people.Insert(new Person { Name = "Alice" });
var loan = new Loan { Amount = 1000, Borrower = DBRef<Person>.To(borrowerId) };
Console.WriteLine(loan.Borrower.HasValue); // true — reference exists
Console.WriteLine(loan.Borrower.IsLoaded); // false — entity not loaded yet
// loan.Borrower.Value would throw InvalidOperationException hereExample 2: After Query Load
var loaded = loans.First(l => l.Amount == 5000);
Console.WriteLine(loaded.Borrower.HasValue); // true
Console.WriteLine(loaded.Borrower.IsLoaded); // true
Console.WriteLine(loaded.Borrower.Value.Name); // "Alice" — safe to accessDBRef Properties
Id | The database Id of the referenced entity (0 if empty) |
HasValue | true if a reference exists (Id != 0) |
IsLoaded | true if the entity was loaded by the query |
Value | The loaded entity (throws InvalidOperationException if not loaded or empty) |
Factory Methods
DBRef<T>.To(id) | Reference an existing entity by its database Id |
DBRef<T>.From(entity) | Cascade-insert a new entity, then reference it |
DBRef<T>.None | Empty reference (no target) |
Collection Reference (DBRefMany) Since v1.1
Use DBRefMany<T> to reference multiple documents. It implements IList<T> with change tracking — SoloDB computes the diff automatically on Update:
Important: Initialize DBRefMany<T> with = new DBRefMany<T>(). Insert tolerates null and creates no links, and loading from the database gives you an empty tracked collection. Update and Upsert reject null with an error.
public class Tag
{
public long Id { get; set; }
public string Label { get; set; }
}
public class Article
{
public long Id { get; set; }
public string Title { get; set; }
public DBRefMany<Tag> Tags { get; set; } = new();
}
var tags = db.GetCollection<Tag>();
var articles = db.GetCollection<Article>();
var article = new Article { Title = "Breaking News" };
article.Tags.Add(new Tag { Label = "News" }); // Cascade-insert
articles.Insert(article);
// Load and modify
var loaded = articles.First(a => a.Title == "Breaking News");
loaded.Tags.Add(new Tag { Label = "Tech" }); // Add a new tag
loaded.Tags.RemoveAt(0); // Remove first tag
articles.Update(loaded); // Diff is computed automaticallyOrdering: DBRefMany<T> does not define a stable order unless your query applies one explicitly. For deterministic results with boundary-sensitive operators such as First, Last, Take, or Skip, start with OrderBy or OrderByDescending.
Delete Policies Since v1.1
Control what happens when referenced documents are deleted using the [SoloRef] attribute:
public class Order
{
public long Id { get; set; }
// Block deletion of the referenced Customer if orders reference it
[SoloRef(OnDelete = DeletePolicy.Restrict)]
public DBRef<Customer> Customer { get; set; }
// When this order is deleted, unlink items (items survive)
[SoloRef(OnOwnerDelete = DeletePolicy.Unlink)]
public DBRefMany<OrderItem> Items { get; set; } = new();
}OnDelete (target entity is deleted)
Restrict (default) | Block the delete if any references exist |
Cascade | Also delete the entity holding this reference |
Unlink | Remove the reference (set to None / remove link row) |
OnOwnerDelete (owner entity is deleted)
Deletion (default) | Unlink, then delete targets with zero remaining references |
Unlink | Remove link rows only, targets always survive |
Restrict | Block owner deletion if any links exist |
The default behavior in plain English is: deleting a target is blocked while something still points to it, and deleting an owner removes its links and then deletes any targets left with zero references.
Note: OnOwnerDelete = Cascade is not supported for DBRefMany and is rejected at schema build time.
Delete-Policy Combination Matrix
| OnDelete \ OnOwnerDelete | Deletion | Unlink | Restrict |
Restrict | Block target deletes; owner delete unlinks and removes zero-reference targets | Block target deletes; owner delete only unlinks | Block target deletes and block owner deletes while links exist |
Cascade | Deleting the target also deletes the owner; owner delete still unlinks and removes zero-reference targets | Deleting the target also deletes the owner; owner delete only unlinks | Deleting the target also deletes the owner; owner delete is still blocked while links exist |
Unlink | Deleting the target unlinks references; owner delete unlinks and removes zero-reference targets | Pure unlink behavior in both directions | Deleting the target unlinks references; owner delete is blocked while links exist |
Typed-ID References Since v1.1
If your target entity uses a custom ID type (via [SoloId]), use the two-parameter generic form DBRef<T, TId>. The [SoloId] attribute requires an IIdGenerator<T> implementation that generates unique identifiers:
// Helper that derives a unique code from the country name
public static class UniqueCountryTag
{
public static string Get(Country c) => c.Name.Substring(0, 2).ToUpperInvariant();
}
// User-defined generator implementing IIdGenerator<T>
public class CountryCodeGenerator : IIdGenerator<Country>
{
public object GenerateId(ISoloDBCollection<Country> collection, Country document)
=> UniqueCountryTag.Get(document); // Derive code from Name
public bool IsEmpty(object id) => string.IsNullOrEmpty(id as string);
}
public class Country
{
public long Id { get; set; } // Internal row Id required by DBRef/DBRefMany
[SoloId(typeof(CountryCodeGenerator))]
public string Code { get; set; } // Auto-generated from Name (e.g. "UN", "GE", "JA")
public string Name { get; set; }
}
public class Office
{
public long Id { get; set; }
public string City { get; set; }
public DBRef<Country, string> Country { get; set; }
}To() — Reference by Custom ID (Reuse)
.To(id) resolves to an existing record by its [SoloId] value. No new entity is created:
var countries = db.GetCollection<Country>();
var offices = db.GetCollection<Office>();
// Code auto-generated via UniqueCountryTag.Get
countries.Insert(new Country { Name = "United States" }); // Code → "UN"
countries.Insert(new Country { Name = "Germany" }); // Code → "GE"
// Reference existing country by generated code
offices.Insert(new Office
{
City = "New York",
Country = DBRef<Country, string>.To("UN")
});
// Same country, different office — no duplicate created
offices.Insert(new Office
{
City = "San Francisco",
Country = DBRef<Country, string>.To("UN")
});From() — Cascade Insert (No Reuse)
.From(entity) always cascade-inserts a new entity. If the [SoloId] value already exists, the insert fails with a unique constraint violation:
// This cascade-inserts a new Country — works when "JA" doesn't exist yet
offices.Insert(new Office
{
City = "Tokyo",
Country = DBRef<Country, string>.From(new Country { Name = "Japan" }) // Code → "JA"
});
// This FAILS — "UN" already exists (United States), From() tries to insert a duplicate
// Throws InvalidOperationException (UNIQUE constraint failed)
offices.Insert(new Office
{
City = "Chicago",
Country = DBRef<Country, string>.From(new Country { Name = "United States" })
});Note: Use .To(id) to reference existing targets. Use .From(entity) only when creating new targets. Typed-ID resolution requires a unique index on the [SoloId] property (provided automatically by the attribute). The resolver matches exactly one target row — if no match is found, the operation is rejected.
Schema Evolution Since v1.1
SoloDB detects relation schema changes when you call GetCollection<T>() and applies safe transitions automatically. Unsupported transitions raise an error with a descriptive message and a fix instruction. All detection and migration runs at collection-open time, not at individual entity access.
Supported Transitions
| Transition | Outcome | Details |
Add DBRef or DBRefMany property | Allowed | Link table auto-created via CREATE TABLE IF NOT EXISTS. Catalog row inserted. |
| Remove relation (no persisted links) | Allowed | Catalog row orphaned, link table empty or absent. No error. |
| Single → Many (widen) | Allowed | Atomic table-recreate within the current transaction. The link table is rebuilt with UNIQUE(SourceId, TargetId) replacing UNIQUE(SourceId). Row count is verified before and after. |
Add typed-id (DBRef<T> → DBRef<T,TId>) | Allowed | No link table change. Requires a unique index on the target's [SoloId] property — raises an error if missing. |
Remove typed-id (DBRef<T,TId> → DBRef<T>) | Allowed | Typed-id resolution disabled. No link table change. |
Change OnDelete policy | Allowed | Catalog metadata updated via upsert. No link table DDL change. New policy enforced at application layer on next deletion. |
Change OnOwnerDelete policy | Allowed | Same catalog metadata update. Exception: DBRefMany with OnOwnerDelete = Cascade is rejected at build time. |
Rejected Transitions
| Transition | Error |
| Remove relation (persisted links exist) | "relation property was removed but persisted link data exists" |
| Many → Single (narrow) | "relation kind narrowing not supported" |
| Change target type | "relation target type changed" |
| Single → Many (duplicate source-target pairs) | "forward migration failed ... duplicate (SourceId, TargetId) pairs" |
Important Notes
- Rename a relation property is equivalent to removing the old property and adding a new one. If the old property has persisted link data, the removal is rejected.
- Policy changes update the catalog metadata only. They do not modify link table DDL. Policies are enforced at the application layer by the relation engine, not by SQLite constraints.
Warning: SoloRef.Unique changes are not applied retroactively. The unique constraint takes effect only when the link table is first created. If you need to add or remove relation uniqueness later, rebuild that relation table deliberately instead of expecting the old one to mutate in place.
// Adding a new relation property — link table created automatically
public class Order
{
public long Id { get; set; }
public DBRef<Customer> Customer { get; set; }
public DBRef<Person> SalesRep { get; set; } // New property — safe to add
}
var orders = db.GetCollection<Order>(); // Link table for SalesRep created here
// Changing a delete policy — takes effect immediately
public class Order
{
public long Id { get; set; }
[SoloRef(OnDelete = DeletePolicy.Cascade)] // Changed from default Restrict
public DBRef<Customer> Customer { get; set; }
}
var orders = db.GetCollection<Order>(); // Catalog updated, new policy activeRelation Queries Expanded in v1.2.0
This section covers relation queries, including DBRefMany, GroupJoin, left joins, and relation-query indexing.
Querying Relations Since v1.1
By default, SoloDB loads referenced documents when it reads a query result. You can also filter and project through relations directly in LINQ:
// Filter by referenced entity's Id
var aliceLoans = loans.Where(l => l.Borrower.Id == aliceId).ToList();
// Filter by DBRefMany content
var taggedArticles = articles.Where(a =>
a.Tags.Any(t => t.Label == "News")
).ToList();Include and Exclude
Include and Exclude control which relations are loaded. They do not change which relation predicates SoloDB can translate.
Includelimits loading to the selected relations.Excludeleaves the relation id available but does not fillValue.- The same relation cannot appear in both
IncludeandExclude.
DBRefMany LINQ Since v1.2.0
DBRefMany queries listed below are translated to SQL. Unsupported queries stop immediately with an error instead of running part of the work in memory.
Example models used below
public class Container
{
public long Id { get; set; }
public string Label { get; set; }
public DBRefMany<Item> Items { get; set; } = new();
}
public class Item
{
public long Id { get; set; }
public string Name { get; set; }
public bool Active { get; set; }
public int Score { get; set; }
}DBRefMany operator reference
| Operator | Status | Notes |
Where | Supported | |
Any, All | Supported | With or without predicate |
Count, LongCount | Supported | With or without predicate |
Sum, Average | Supported | Selector required |
Min, Max | Supported | Selector required |
OrderBy, OrderByDescending | Supported | |
ThenBy, ThenByDescending | Supported | |
Take, Skip | Requires ordering | Add OrderBy before these |
First, FirstOrDefault | Requires ordering | Add OrderBy for deterministic results |
Last, LastOrDefault | Requires ordering | Add OrderBy for deterministic results |
Single, SingleOrDefault | Supported | |
ElementAt, ElementAtOrDefault | Requires ordering | Add OrderBy before these |
TakeWhile, SkipWhile | Requires ordering | Add OrderBy before these |
Select | Supported | Into scalars or anonymous types |
SelectMany | Supported | Inside Select projection; supports inner Where, Select, OfType |
Distinct, DistinctBy | Supported | |
UnionBy, IntersectBy, ExceptBy | Supported | |
GroupBy | Supported | With aggregate operators such as Count, Sum, Min, Max, and Average |
CountBy | Supported | |
Contains | Supported | On scalar projections |
DefaultIfEmpty | Supported | |
OfType | Supported | Filters by exact stored type discriminator |
Cast | Rejected | Use OfType instead |
Aggregate | Rejected | Cannot translate custom folds to SQL |
Zip, Reverse | Rejected | No SQL equivalent |
Append, Prepend | Rejected | No SQL equivalent |
SequenceEqual | Rejected | No SQL equivalent |
Note: DBRefMany<T> does not define a stable element order. For deterministic results with operators marked Requires ordering, add OrderBy or OrderByDescending before them. Average uses SQLite AVG, so decimal results follow SQLite REAL precision.
Nested relation queries
Supported nested relation queries are translated to a single SQLite statement with correlated subqueries.
// Three hops: Order -> OrderLine (DBRefMany) -> Product (DBRef) -> Tags (DBRefMany)
var flagged = orders
.Where(o => o.Lines.Any(line =>
line.Product.Value.Tags.Any(t => t.Label == "hazardous")))
.ToList();Supported nested relation queries can resolve in one SQLite statement. Nested relation navigation is supported up to 10 relation hops. Beyond that limit, the query is rejected before execution.
Error: Relation chain is too deep or circular (depth > 10).
Reason: The query exceeds the supported relation traversal depth.
Fix: Reduce relation depth or split the query into multiple steps.Filtered aggregate example
// Find containers where the average score of active items exceeds 20
var highScoring = containers
.Where(c => c.Items.Where(i => i.Active).Average(i => i.Score) > 20)
.ToList();
// Count and project in one pass
var summary = containers
.Select(c => new { c.Label, Total = c.Items.Count(), ActiveCount = c.Items.Count(i => i.Active) })
.ToList();Note: Average uses SQLite AVG. Decimal results are converted back to .NET decimal after SQLite computes them with REAL precision.
Ordering and boundary operators
DBRefMany<T> does not define a stable element order. Use explicit ordering for deterministic results with boundary-sensitive operators such as First, Last, Take, and Skip. TakeWhile and SkipWhile require explicit ordering.
// Wrap the nested projection so the ordered subset stays addressable in C#
var topThree = containers
.Where(c => c.Label == "main")
.Select(c => new
{
Names = c.Items.OrderByDescending(i => i.Score).Take(3).Select(i => i.Name)
})
.Single()
.Names
.ToArray();// Ordered TakeWhile and SkipWhile are supported
var middleBand = containers
.Select(c => new
{
Names = c.Items
.OrderBy(i => i.Score)
.SkipWhile(i => i.Score < 10)
.TakeWhile(i => i.Score <= 50)
.Select(i => i.Name)
})
.ToList();Note: Without an explicit order, TakeWhile and SkipWhile are rejected with InvalidOperationException. The current message starts with Error: TakeWhile/SkipWhile requires explicit ordering.
SelectMany inside a projection
This SelectMany form is supported:
public class Portfolio
{
public long Id { get; set; }
public DBRefMany<Book> Books { get; set; } = new();
}
public class Book
{
public long Id { get; set; }
public DBRefMany<OptionLeg> Legs { get; set; } = new();
}
public abstract class OptionLeg
{
public long Id { get; set; }
}
public sealed class ProjectionOptionLeg : OptionLeg
{
public decimal Strike { get; set; }
}
var legCounts = portfolios
.Select(p => new
{
p.Id,
ProjectionLegCount = p.Books.SelectMany(b => b.Legs.OfType<ProjectionOptionLeg>()).Count()
})
.ToList();This example is supported. Other projection-local SelectMany queries may not be. The OfType<ProjectionOptionLeg>() filter matches the exact stored type discriminator.
Compatibility notes
- F#
option<DBRef<_>>andoption<DBRefMany<_>>are not supported and are rejected at schema build time.
GroupJoin and Left Joins Since v1.2.0
SoloDB 1.2.0 supports grouped GroupJoin queries and the usual GroupJoin + SelectMany + DefaultIfEmpty left-join pattern. Grouped aggregates such as Count, Sum, Min, Max, and Average are translated to SQL aggregate functions.
Grouped aggregate example
// For each event, count tickets and sum VIP revenue
var summary = events.AsQueryable()
.GroupJoin(
tickets.AsQueryable(),
e => e.Id,
t => t.EventId,
(e, group) => new
{
e.Title,
TicketCount = group.Count(),
VipRevenue = group.Where(t => t.Tier == "vip").Sum(t => t.Price)
})
.OrderBy(x => x.Title)
.ToList();Events with no matching tickets produce TicketCount = 0 and VipRevenue = 0, not a missing row. The GroupJoin + SelectMany + DefaultIfEmpty left-join pattern also translates to SQL LEFT JOIN.
Indexing Relations Since v1.1
You can index DBRef.Id to speed up queries that filter by foreign key. This creates an index on the JSON-extracted reference Id stored in the owner document, which is separate from the link table's internal indexes.
Owner-Side Index (DBRef.Id)
When you write a LINQ query filtering on a DBRef Id, SoloDB translates it to a jsonb_extract filter on the owner table:
// LINQ query:
var aliceLoans = loans.Where(l => l.Borrower.Id == aliceId).ToList();
// Translates to SQL:
// SELECT ... FROM Loan WHERE jsonb_extract(Value, '$.Borrower') = @p0
// Without an index: SQLite scans every row, extracting the JSON field per row.
// With an index: SQLite uses the expression index for direct lookup.
// Create the index:
loans.EnsureIndex(l => l.Borrower.Id);
// Composite index - useful for filtering by reference + another field:
loans.EnsureIndex(l => new ValueTuple<long, decimal>(l.Borrower.Id, l.Amount));Note: C# expression trees do not support tuple literal syntax. Composite index expressions must use the ValueTuple constructor form as shown above. In F#, the equivalent tuple syntax (u.Field1, u.Field2) works directly.
The query-plan effect is verifiable with SQLite plan extraction (EXPLAIN QUERY PLAN): before creating the index you get a table-scan plan, and after creating it an index-search plan is available (for deterministic verification you can force the index with INDEXED BY).
// Plan before index (owner table scan):
// EXPLAIN QUERY PLAN SELECT ... FROM Loan WHERE jsonb_extract(Value, '$.Borrower') = @p0;
// -> SCAN Loan
loans.EnsureIndex(l => l.Borrower.Id);
// Plan after index (forced owner expression-index lookup):
// EXPLAIN QUERY PLAN SELECT ... FROM Loan INDEXED BY IX_Loan_Borrower
// WHERE jsonb_extract(Value, '$.Borrower') = @p0;
// -> SEARCH Loan USING INDEX IX_Loan_BorrowerTarget-Side Queries
Queries that navigate through DBRef.Value or filter DBRefMany content resolve through JOIN or EXISTS subqueries against the link and target tables. Index the target collection instead:
// This query JOINs through the link table to the Person table:
var loans = loans.Where(l => l.Borrower.Value.Name == "Alice").ToList();
// Index the TARGET: people.EnsureIndex(p => p.Name);
// DBRefMany queries use EXISTS subqueries:
var tagged = articles.Where(a => a.Tags.Any(t => t.Label == "News")).ToList();
// Index the TARGET: tags.EnsureIndex(t => t.Label);What Cannot Be Indexed
SoloDB rejects index expressions that navigate through relations at build time:
EnsureIndex(l => l.Borrower.Value.Name) | Rejected - navigates through DBRef.Value (resolves via JOIN) |
EnsureIndex(a => a.Tags.Count) | Rejected - DBRefMany properties resolve through link tables |
Note: Owner-side DBRef.Id indexes are not redundant with the link table's SourceId/TargetId indexes. They help different queries: the owner-side index speeds up filtering on the owner collection's JSON field, while link table indexes speed up JOIN traversals.
Transactions Since v1.0.0
For operations that must succeed or fail together, use transactions. If any exception occurs, all changes are automatically rolled back.
Root transaction scopes use SQLite BEGIN IMMEDIATE. Calling WithTransaction inside an active transaction uses a nested SQLite SAVEPOINT.
Basic Transaction
db.WithTransaction(tx =>
{
var accounts = tx.GetCollection<Account>();
var from = accounts.GetById(fromAccountId);
var to = accounts.GetById(toAccountId);
if (from.Balance < amount)
throw new InvalidOperationException("Insufficient funds");
from.Balance -= amount;
to.Balance += amount;
accounts.Update(from);
accounts.Update(to);
});
// If we get here, transaction committed successfullyTransaction with Return Value
var orderId = db.WithTransaction(tx =>
{
var orders = tx.GetCollection<Order>();
var inventory = tx.GetCollection<InventoryItem>();
// Create order and update inventory atomically
var order = new Order { CustomerId = customerId, Total = total };
orders.Insert(order);
foreach (var item in orderItems)
{
var inv = inventory.Single(i => i.ProductId == item.ProductId);
inv.Quantity -= item.Quantity;
inventory.Update(inv);
}
return order.Id;
});Automatic Rollback
try
{
db.WithTransaction(tx =>
{
var users = tx.GetCollection<User>();
users.Insert(new User { Name = "Test" });
// This exception causes automatic rollback
throw new Exception("Something went wrong!");
});
}
catch (Exception)
{
// The user was NOT inserted - transaction rolled back
}Async Transactions
var result = await db.WithTransactionAsync(async tx =>
{
var users = tx.GetCollection<User>();
users.Insert(new User { Name = "Alice" });
return users.Count();
});Nested Transactions Since v1.1
Calling WithTransaction inside an existing transaction creates a nested SQLite SAVEPOINT. If the inner operation fails, only the inner changes are rolled back - the outer transaction continues:
db.WithTransaction(tx =>
{
var users = tx.GetCollection<User>();
users.Insert(new User { Name = "Alice" });
try
{
tx.WithTransaction(inner =>
{
var orders = inner.GetCollection<Order>();
orders.Insert(new Order { Total = -1 });
throw new Exception("Invalid order");
});
}
catch (Exception)
{
// Only the order insert was rolled back
// Alice's insert is still part of the outer transaction
}
users.Insert(new User { Name = "Bob" });
// Both Alice and Bob are committed when the outer transaction completes
});Transactions in Event Handlers
Event handlers execute during active SQL statements. Calling WithTransaction on the event context's ISoloDB throws NotSupportedException at runtime because SQLite cannot open savepoints while a statement is in progress. Use the event context directly instead:
var orders = db.GetCollection<Order>();
// This compiles but throws NotSupportedException at runtime:
orders.OnInserted(ctx =>
{
ctx.WithTransaction(tx => { ... }); // Throws NotSupportedException
return SoloDBEventsResult.EventHandled;
});
// Instead, use the event context directly:
orders.OnInserted(ctx =>
{
var audit = ctx.GetCollection<AuditLog>();
audit.Insert(new AuditLog { Action = "OrderCreated" });
return SoloDBEventsResult.EventHandled;
});FileSystem in Transactions
FileSystem behavior depends on which context you access it from:
db.FileSystem— single-statement operations (Delete,SetMetadata,DeleteMetadata,SetFileModificationDate,SetFileCreationDate) are atomic via SQLite autocommit.db.FileSystem— multi-step structural operations (MoveFile,MoveReplaceFile,MoveDirectory,CopyFile,CopyDirectory,WriteAt,UploadBulk) wrap their own internal transaction and are fully atomic.db.FileSystem—UploadandUploadAsyncwrap their own internal transaction and are fully atomic, same as other multi-step operations above.tx.FileSystem— all operations participate in the enclosing transaction. A rollback undoes uploaded chunks, metadata changes, and structural operations atomically.
FileStorage coordination is SQLite-transaction-native in this release; no extra process-local mutex is used.
// Transactional upload: full rollback on failure
db.WithTransaction(tx =>
{
var users = tx.GetCollection<User>();
users.Insert(new User { Name = "Alice" });
tx.FileSystem.Upload("/avatars/alice.png", imageStream);
// If anything throws here, both user insert and file upload roll back.
});Events API Since v1.0.0
SoloDB provides an event system that lets you react to document changes. Register handlers to run custom logic before or after insert, update, and delete operations.
Event Types
Six event types are available, split into "before" and "after" events:
| Before Events | Run before the operation commits. Can cause rollback. |
OnInserting | Before a document is inserted |
OnUpdating | Before a document is updated |
OnDeleting | Before a document is deleted |
| After Events | Run after the operation, but still in the same transaction. |
OnInserted | After a document is inserted |
OnUpdated | After a document is updated |
OnDeleted | After a document is deleted |
Registering Handlers
Collections implement the events interface directly:
var users = db.GetCollection<User>();
// Before-insert handler
users.OnInserting(ctx =>
{
Console.WriteLine($"Inserting user: {ctx.Item.Name}");
return SoloDBEventsResult.EventHandled;
});
// After-insert handler
users.OnInserted(ctx =>
{
Console.WriteLine($"Inserted user with ID: {ctx.Item.Id}");
return SoloDBEventsResult.EventHandled;
});
// Update handler with access to old and new values
users.OnUpdating(ctx =>
{
Console.WriteLine($"Updating from {ctx.OldItem.Name} to {ctx.Item.Name}");
return SoloDBEventsResult.EventHandled;
});Handler Return Values
Handlers must return a SoloDBEventsResult value:
EventHandled | Handler completed successfully. Continue with operation. |
RemoveHandler | Handler completed and should be unregistered (one-time handler). |
// One-time handler that removes itself after first execution
users.OnInserting(ctx =>
{
Console.WriteLine("This runs only once!");
return SoloDBEventsResult.RemoveHandler;
});Handler Context
The context object provides access to the item and a scoped database connection:
users.OnInserting(ctx =>
{
// Access the item being inserted/updated/deleted
var user = ctx.Item;
// Access the collection name
string collectionName = ctx.CollectionName;
// The context itself implements ISoloDB - use it for related operations
var logs = ctx.GetCollection<AuditLog>();
logs.Insert(new AuditLog { Action = "UserCreated", UserId = user.Id });
return SoloDBEventsResult.EventHandled;
});For update events, the context provides both old and new values:
users.OnUpdating(ctx =>
{
var oldUser = ctx.OldItem; // Value before update
var newUser = ctx.Item; // Value after update
if (oldUser.Email != newUser.Email)
{
Console.WriteLine($"Email changed from {oldUser.Email} to {newUser.Email}");
}
return SoloDBEventsResult.EventHandled;
});Important: Always use the ctx parameter for database operations inside handlers. Using an external SoloDB instance will cause a database lock error.
Unregistering Handlers
To remove a handler, keep a reference and call Unregister:
// Keep a reference to the handler
InsertingHandler<User> myHandler = ctx =>
{
Console.WriteLine("Handler running");
return SoloDBEventsResult.EventHandled;
};
// Register it
users.OnInserting(myHandler);
// Later, unregister it
users.Unregister(myHandler);Important: Do not call Unregister from within a handler. This will cause an error. To self-remove a handler, return SoloDBEventsResult.RemoveHandler instead.
Exception Handling and Rollback
All event handlers run inside the same SQLite transaction as the operation. If any handler throws an exception, the entire operation rolls back:
users.OnInserting(ctx =>
{
if (ctx.Item.Email == null)
throw new InvalidOperationException("Email is required");
return SoloDBEventsResult.EventHandled;
});
try
{
users.Insert(new User { Name = "Test", Email = null });
}
catch (SqliteException ex)
{
// Insert was rolled back - user was NOT inserted
Console.WriteLine(ex.Message); // Contains "Email is required"
}Important: After-event handlers (OnInserted, OnUpdated, OnDeleted) also run inside the transaction. If they throw, the operation rolls back even though the database change already happened. This is SQLite trigger behavior and ensures consistency.
Multiple Handlers
You can register multiple handlers for the same event. They execute in registration order. If one throws, subsequent handlers do not run:
users.OnInserting(ctx =>
{
Console.WriteLine("First handler");
return SoloDBEventsResult.EventHandled;
});
users.OnInserting(ctx =>
{
Console.WriteLine("Second handler");
throw new Exception("Oops!"); // Stops here, third won't run
});
users.OnInserting(ctx =>
{
Console.WriteLine("Third handler"); // Never reached
return SoloDBEventsResult.EventHandled;
});Use Cases
- Audit logging - Record who changed what and when
- Validation - Enforce business rules before changes commit
- Cascading updates - Update related documents automatically
- Cache invalidation - Clear caches when data changes
- Notifications - Trigger external notifications on changes
Polymorphic Collections Since v1.0.0
Store different derived types in a single collection and query them by base type or filter by concrete type.
Abstract Base Class
public abstract class Figure
{
public long Id { get; set; }
public string Color { get; set; }
public abstract double CalculateArea();
}
public class Circle : Figure
{
public double Radius { get; set; }
public override double CalculateArea() => Math.PI * Radius * Radius;
}
public class Rectangle : Figure
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea() => Width * Height;
}Usage
var figures = db.GetCollection<Figure>();
// Insert different types
figures.Insert(new Circle { Color = "Red", Radius = 5.0 });
figures.Insert(new Rectangle { Color = "Blue", Width = 4.0, Height = 6.0 });
// Query all figures (returns properly typed objects)
var allFigures = figures.ToList();
// allFigures[0] is Circle, allFigures[1] is Rectangle
// Query by base class properties
var blueFigures = figures.Where(s => s.Color == "Blue").ToList();
// Filter by concrete type using OfType<T>()
var circles = figures.OfType<Circle>().ToList();
var largeCircles = figures.OfType<Circle>()
.Where(c => c.Radius > 3.0)
.ToList();Note: SoloDB translates OfType<T>() using the stored $type discriminator in JSONB. This is stricter than normal CLR assignability: it matches rows stored exactly as T, not "base type plus every derived type."
How It Works
SoloDB stores type information in a special $type field in the JSON when the collection is based on an abstract class or interface. This allows correct deserialization back to the original type, and it is also what relation-query OfType<T>() filters use through jsonb_extract.
Direct SQL Access Since v1.0.0
For complex queries or operations not covered by LINQ, access SQLite directly. SoloDB provides a Dapper-like micro-ORM API with high-performance object mapping using compiled expression trees.
Borrowing a Connection
// Borrow a connection from the pool
using var conn = db.Connection.Borrow();Dapper-Like Query API
The borrowed connection already exposes these methods directly. If you want the same helpers on a plain SqliteConnection, add the following import:
using static SoloDatabase.SQLiteTools.IDbConnectionExtensions;// Execute non-query commands (CREATE, INSERT, UPDATE, DELETE)
// Returns number of rows affected
conn.Execute("CREATE TABLE IF NOT EXISTS Logs (Id INTEGER PRIMARY KEY, Message TEXT)");
conn.Execute("INSERT INTO Logs (Message) VALUES (@msg)", new { msg = "Hello" });
// Query multiple rows - returns IEnumerable<T>
var logs = conn.Query<LogEntry>("SELECT * FROM Logs WHERE Id > @id", new { id = 100 });
// Query first row (throws if no results)
var count = conn.QueryFirst<int>("SELECT COUNT(*) FROM Logs");
// Query first row or default (returns null/default if no results)
var log = conn.QueryFirstOrDefault<LogEntry>("SELECT * FROM Logs WHERE Id = @id", new { id = 999 });Object Mapping
The query methods automatically map SQL results to your types. For complex types, SoloDB builds and compiles LINQ expression trees on first use, creating optimized mappers that match column names to property/field names:
// Map to a class
public class LogEntry
{
public long Id { get; set; }
public string Message { get; set; }
}
var logs = conn.Query<LogEntry>("SELECT Id, Message FROM Logs");
// Map to anonymous types
var results = conn.Query<dynamic>("SELECT Id, Message FROM Logs");
// Map to primitives
var ids = conn.Query<long>("SELECT Id FROM Logs");Accessing Collection Data
// Documents are stored as JSONB in the 'Value' column
// Use SQLite's json_extract to query specific fields
var rawUsers = conn.Query<dynamic>(
"SELECT Id, json_extract(Value, '$.Name') as Name FROM User WHERE json_extract(Value, '$.IsActive') = 1"
);File Storage Since v1.0.0
SoloDB includes a complete hierarchical file storage system stored directly in the database. Files are split into 16KB chunks, compressed using Snappy, and stored in SQLite. This provides:
- Partial reads - Read only what you need without loading the entire file
- Sparse file support - Write at any offset; unwritten areas don't consume space
- Automatic compression - Snappy compression reduces storage size
- Transactional safety - File operations participate in database transactions
- Metadata support - Attach key-value metadata to files and directories
Accessing the FileSystem
var fs = db.FileSystem;Upload and Download
SoloDB normalizes slash direction and adds the leading / if you omit it. Names are case-sensitive, and Unicode names are supported.
// Upload from a stream
using (var stream = File.OpenRead("report.pdf"))
{
fs.Upload("/documents/reports/2024-q4.pdf", stream);
}
// Download to a stream
using (var output = File.Create("downloaded.pdf"))
{
fs.Download("/documents/reports/2024-q4.pdf", output);
}
// Check existence and delete
bool exists = fs.Exists("/documents/reports/2024-q4.pdf");
fs.DeleteFileAt("/documents/reports/2024-q4.pdf");SoloDB stores files in 16KB chunks. There is no application-level maximum file size or metadata limit — practical limits come from SQLite database size and available storage.
Stream-Based Access (Like File.Open)
The OpenOrCreateAt method returns a standard Stream that works just like File.Open(). You can use it with any .NET stream API:
// Compare: System.IO file access
using (var fileStream = File.Open("local.txt", FileMode.OpenOrCreate))
{
fileStream.Write(data, 0, data.Length);
fileStream.Position = 0;
fileStream.Read(buffer, 0, buffer.Length);
}
// SoloDB file access - same API!
using (var fileStream = fs.OpenOrCreateAt("/data/log.txt"))
{
fileStream.Write(data, 0, data.Length);
fileStream.Position = 0;
fileStream.Read(buffer, 0, buffer.Length);
}Works with StreamReader/StreamWriter too:
// Write text
using (var stream = fs.OpenOrCreateAt("/logs/app.log"))
using (var writer = new StreamWriter(stream))
{
writer.WriteLine($"[{DateTime.UtcNow}] Application started");
writer.WriteLine($"[{DateTime.UtcNow}] Processing...");
}
// Read text
using (var stream = fs.OpenOrCreateAt("/logs/app.log"))
using (var reader = new StreamReader(stream))
{
string contents = reader.ReadToEnd();
}Random Access (Partial Reads/Writes)
Unlike document storage, FileSystem supports efficient partial access:
// Write at specific offset (creates sparse file if needed)
byte[] data = GetSomeData();
fs.WriteAt("/data/sparse.bin", 1024 * 1024, data); // Write at 1MB offset
// Read from specific offset - doesn't load entire file
byte[] chunk = fs.ReadAt("/data/sparse.bin", 1024 * 1024, data.Length);
// Sparse files: unwritten areas read as zeros, don't consume storage
fs.WriteAt("/sparse.dat", 10_000_000, new byte[] { 1, 2, 3 }); // 10MB offset
// File is NOT 10MB on disk - only the written chunks are storedFile and Directory Metadata
// Set file metadata (key-value pairs)
fs.SetMetadata("/documents/report.pdf", "Author", "Finance Team");
fs.SetMetadata("/documents/report.pdf", "Department", "Accounting");
// Read file info with metadata
var fileInfo = fs.GetAt("/documents/report.pdf");
Console.WriteLine($"Name: {fileInfo.Name}");
Console.WriteLine($"Size: {fileInfo.Length} bytes");
Console.WriteLine($"Created: {fileInfo.Created}");
Console.WriteLine($"Modified: {fileInfo.Modified}");
Console.WriteLine($"Author: {fileInfo.Metadata["Author"]}");
// Delete specific metadata
fs.DeleteMetadata(fileInfo, "Department");
// Directory metadata works the same way
var dir = fs.GetOrCreateDirAt("/documents/archive");
fs.SetDirectoryMetadata(dir, "RetentionPolicy", "7years");
fs.DeleteDirectoryMetadata(dir, "RetentionPolicy");Directory Operations
// Create directory (creates parent directories automatically)
var dir = fs.GetOrCreateDirAt("/documents/archive/2024");
// Get directory info
var dirInfo = fs.GetDirAt("/documents/archive");
// List files in a directory
var files = fs.ListFilesAt("/documents/reports/");
// List subdirectories
var dirs = fs.ListDirectoriesAt("/documents/");
// Recursive listing (files and directories)
var allEntries = fs.RecursiveListEntriesAt("/documents/");
// Lazy recursive listing (memory efficient for large trees)
var lazyEntries = fs.RecursiveListEntriesAtLazy("/");
// Delete directory (must be empty)
fs.DeleteDirAt("/documents/old");Move and Rename Files
// Move/rename a file (throws IOException if destination exists)
fs.MoveFile("/documents/draft.pdf", "/documents/final.pdf");
// Move to different directory
fs.MoveFile("/inbox/file.txt", "/archive/2024/file.txt");
// Move and replace if exists
fs.MoveReplaceFile("/temp/new.pdf", "/documents/report.pdf");Bulk Upload
For uploading many files efficiently in a single transaction:
var files = new List<BulkFileData>
{
new("/logs/app1.log", Encoding.UTF8.GetBytes("Log data 1"), null, null),
new("/logs/app2.log", Encoding.UTF8.GetBytes("Log data 2"), null, null),
new("/images/logo.png", imageBytes, DateTimeOffset.UtcNow, null)
};
fs.UploadBulk(files); // Single transaction for all filesFile Timestamps
Files and directories track Created and Modified timestamps. The Modified timestamp is automatically updated whenever you write to a file or upload new content:
// Modified is automatically updated on writes
fs.WriteAt("/data/file.bin", 0, data); // Modified = now
fs.Upload("/data/file.bin", stream); // Modified = now
// Manually set timestamps when needed
fs.SetFileCreationDate("/archive/old.txt", DateTimeOffset.UtcNow.AddYears(-1));
fs.SetFileModificationDate("/archive/old.txt", DateTimeOffset.UtcNow.AddDays(-30));
// Read timestamps from file info
var info = fs.GetAt("/archive/old.txt");
Console.WriteLine($"Created: {info.Created}");
Console.WriteLine($"Modified: {info.Modified}");Configuration Since v1.0.0
Database Location
// File-based (persistent)
using var db = new SoloDB("path/to/database.db");
using var db = new SoloDB("./relative/path.db");
using var db = new SoloDB(@"C:\absolute\path.db");
// In-memory (lost when disposed)
using var db = new SoloDB("memory:my-database");
// Shared in-memory (accessible by name within process)
using var db1 = new SoloDB("memory:shared");
using var db2 = new SoloDB("memory:shared"); // Same databaseLong-Running Applications
// Singleton pattern for web apps / services
public static class Database
{
public static SoloDB Instance { get; } = new SoloDB("app.db");
}
// Usage
var users = Database.Instance.GetCollection<User>();Database Maintenance
// Optimize query plans (runs ANALYZE)
db.Optimize();
// Backup to another database
using var backup = new SoloDB("backup.db");
db.BackupTo(backup);
// Vacuum into new file (compacts and defragments)
db.VacuumTo("compacted.db");Note: BackupTo cannot cross the file-memory boundary. A file-based database can back up only to another file-based database, and an in-memory database can back up only to another in-memory database. VacuumTo writes a compacted copy to a file on disk.
Query Caching
SoloDB caches prepared SQL statements for performance. The internal SoloDBConfiguration type contains a CachingEnabled flag that controls this behavior. You can manage caching through these methods:
// Disable caching (reduces memory, slower repeated queries)
// Sets config.CachingEnabled = false
db.DisableCaching();
// Re-enable caching
// Sets config.CachingEnabled = true
db.EnableCaching();
// Clear the current cache (frees memory, keeps caching enabled)
db.ClearCache();Caching is enabled by default. Disabling it automatically clears any cached commands. This can be useful for memory-constrained environments or when running many unique one-off queries.
LINQ Translation
SoloDB's LINQ translation is internal. Query behavior is documented under Relation Queries and Querying with LINQ.
Performance Tips Since v1.0.0
The examples below use these simple models:
public class User
{
public long Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public bool NeedsUpdate { get; set; }
public string Status { get; set; }
}
public class Report
{
public long Id { get; set; }
public string Title { get; set; }
public string PdfPath { get; set; }
}1. Use Indexes on Queried Properties
// Without index: Full table scan
var user = users.FirstOrDefault(u => u.Email == "test@example.com");
// With index: Fast lookup
[Indexed]
public string Email { get; set; }2. Use Batch Operations
// Slow: 1000 individual transactions
foreach (var item in items)
collection.Insert(item);
// Fast: Single transaction
collection.InsertBatch(items);3. Use Transactions for Multiple Operations
// Slow: Each update is a separate transaction
foreach (var user in usersToUpdate)
{
user.LastSeen = DateTime.UtcNow;
users.Update(user);
}
// Fast: Single transaction
db.WithTransaction(tx =>
{
var col = tx.GetCollection<User>();
foreach (var user in usersToUpdate)
{
user.LastSeen = DateTime.UtcNow;
col.Update(user);
}
});4. Use Projections for Large Documents
// Slow: Loads entire documents
var names = users.ToList().Select(u => u.Name);
// Fast: Only fetches Name field
var names = users.Select(u => u.Name).ToList();5. Use UpdateMany for Partial Updates
// Slow: Load, modify, save each document
foreach (var user in users.Where(u => u.NeedsUpdate))
{
var u = users.GetById(user.Id);
u.Status = "updated";
users.Update(u);
}
// Fast: Single SQL UPDATE statement
users.UpdateMany(u => u.NeedsUpdate, u => u.Status.Set("updated"));6. Keep Documents Small
SQLite reads the entire JSONB document when accessing any field. Large documents slow down all operations, even simple queries. For large binary data, use the built-in FileSystem API which supports partial reads. Use the Events API to keep files and documents in sync automatically:
// Bad: Storing large data in documents
public class Report
{
public long Id { get; set; }
public string Title { get; set; }
public byte[] PdfContent { get; set; } // Large! Loaded on every access
}
// Good: Store large data in FileSystem and keep the file location in the document
public class Report
{
public long Id { get; set; }
public string Title { get; set; }
public string PdfPath { get; set; } // e.g., "/reports/2024/report-123.pdf"
}
// Use collection events to sync FileSystem with document lifecycle
var reports = db.GetCollection<Report>();
reports.OnDeleted(ctx =>
{
ctx.FileSystem.DeleteFileAt(ctx.Item.PdfPath);
return SoloDBEventsResult.EventHandled;
});
// Read only what you need from FileSystem
byte[] chunk = db.FileSystem.ReadAt("/reports/2024/report-123.pdf", 0, 1024);Benchmark Results vs LiteDB
SoloDB shows strong performance in common operations:
| Insert 10,000 documents | 29% faster than LiteDB |
| Complex LINQ queries | 95% faster than LiteDB |
| GroupBy operations | 57% faster than LiteDB |
| Memory usage | Up to 99% less allocation |
Source: SoloDB vs LiteDB Benchmark
API Reference Since v1.0.0
Complete reference for SoloDB's 59 public types, grouped by function. Behavior notes and larger examples live in the dedicated sections above.
SoloDB
class SoloDB : ISoloDB, IDisposable — The root database handle. Create one per file, share it across threads.
new SoloDB(string source) | Open or create a database. Use a file path for persistent, "memory:name" for in-memory |
ConnectionString | The SQLite connection string for this instance |
FileSystem | Access the built-in file storage API |
GetCollection<T>() | Get a typed collection (name derived from type) |
GetCollection<T>(string name) | Get a typed collection with an explicit name |
GetUntypedCollection(string name) | Get an untyped JsonValue collection for dynamic use |
CollectionExists(string name) | Check if a named collection exists |
CollectionExists<T>() | Check if a typed collection exists |
ListCollectionNames() | List all collection names as string[] |
DropCollection(string name) | Delete a named collection (throws if missing) |
DropCollection<T>() | Delete a typed collection and all its data |
DropCollectionIfExists(string name) | Delete a named collection if it exists |
DropCollectionIfExists<T>() | Delete a typed collection if it exists |
WithTransaction(Action<TransactionalSoloDB>) | Execute inside a BEGIN IMMEDIATE transaction |
WithTransaction<R>(Func<TransactionalSoloDB, R>) | Execute in transaction with a return value |
WithTransactionAsync(Func<TransactionalSoloDB, Task>) | Execute an async transaction |
WithTransactionAsync<R>(Func<TransactionalSoloDB, Task<R>>) | Execute an async transaction with a return value |
Optimize() | Run SQLite ANALYZE to improve query plans |
BackupTo(SoloDB target) | Backup to another database (cannot cross file/memory boundary) |
Vacuum() | Compact the current database file in place |
VacuumTo(string location) | Compact into a new file |
EnableCaching() | Turn prepared-statement caching on |
DisableCaching() | Turn prepared-statement caching off (clears existing cache) |
ClearCache() | Drop the current prepared-statement cache |
static GetSQL<T>(IQueryable<T> query) | Return the SQL generated for a LINQ query |
static ExplainQueryPlan<T>(IQueryable<T> query) | Return SQLite's EXPLAIN QUERY PLAN output for a LINQ query |
Dispose() | Close the database connection and release resources |
ISoloDB
interface ISoloDB : IDisposable — Common database API shared by SoloDB and TransactionalSoloDB.
ConnectionString | The SQLite connection string |
FileSystem | Access the file storage API |
GetCollection<T>() | Get a typed collection |
GetCollection<T>(string name) | Get a typed collection with explicit name |
GetUntypedCollection(string name) | Get an untyped collection |
CollectionExists(string name) | Check if a named collection exists |
CollectionExists<T>() | Check if a typed collection exists |
ListCollectionNames() | List all collection names |
DropCollection(string name) | Delete a named collection |
DropCollection<T>() | Delete a typed collection |
DropCollectionIfExists(string name) | Delete a named collection if it exists |
DropCollectionIfExists<T>() | Delete a typed collection if it exists |
WithTransaction(Action<ISoloDB>) | Execute inside a transaction |
WithTransaction<R>(Func<ISoloDB, R>) | Execute in transaction with return value |
WithTransactionAsync(Func<ISoloDB, Task>) | Async transaction |
WithTransactionAsync<R>(Func<ISoloDB, Task<R>>) | Async transaction with return value |
Optimize() | Run SQLite ANALYZE |
TransactionalSoloDB
class TransactionalSoloDB : ISoloDB — Transaction-scoped database handle. Received inside WithTransaction callbacks. Do not store or use outside the callback.
ConnectionString | The SQLite connection string |
FileSystem | File storage API (participates in the transaction) |
GetCollection<T>() | Get a typed collection within the transaction |
GetCollection<T>(string name) | Get a named typed collection within the transaction |
GetUntypedCollection(string name) | Get an untyped collection within the transaction |
CollectionExists(string name) | Check if a named collection exists |
CollectionExists<T>() | Check if a typed collection exists |
ListCollectionNames() | List all collection names |
DropCollection(string name) | Delete a named collection |
DropCollection<T>() | Delete a typed collection |
DropCollectionIfExists(string name) | Delete a named collection if it exists |
DropCollectionIfExists<T>() | Delete a typed collection if it exists |
WithTransaction(Action<ISoloDB>) | Nested transaction (uses savepoints) |
WithTransaction<R>(Func<ISoloDB, R>) | Nested transaction with return value |
WithTransactionAsync(Func<ISoloDB, Task>) | Async nested transaction |
WithTransactionAsync<R>(Func<ISoloDB, Task<R>>) | Async nested transaction with return value |
Optimize() | Run SQLite ANALYZE |
ISoloDBCollection<T>
interface ISoloDBCollection<T> : ISoloDBCollectionEvents<T>, IOrderedQueryable<T> — Typed collection. Use as a LINQ query root and for CRUD operations.
| Properties | |
Name | Collection name |
InTransaction | Whether this collection is operating inside a transaction |
IncludeType | Whether type discriminator info is stored for this collection |
| Insert | |
Insert(T item) | Insert a document, returns its long ID |
InsertBatch(IEnumerable<T> items) | Batch insert, returns all IDs |
InsertOrReplace(T item) | Upsert a single document based on unique index |
InsertOrReplaceBatch(IEnumerable<T> items) | Batch upsert based on unique indexes |
| Read | |
GetById(long id) | Get by ID (throws if not found) |
GetById<TId>(TId id) | Get by custom ID type (throws if not found) |
TryGetById(long id) | Get by ID, returns FSharpOption<T> |
TryGetById<TId>(TId id) | Get by custom ID type, returns FSharpOption<T> |
| Update | |
Update(T item) | Replace the entire document (matched by ID) |
UpdateMany(filter, transforms) | Partial update of matching documents using transform expressions |
ReplaceOne(filter, item) | Replace the first matching document |
ReplaceMany(filter, item) | Replace all matching documents |
| Delete | |
Delete(long id) | Delete by ID |
Delete<TId>(TId id) | Delete by custom ID type |
DeleteOne(filter) | Delete the first match |
DeleteMany(filter) | Delete all matches |
| Indexing | |
EnsureIndex(expression) | Create a non-unique index on a property |
EnsureUniqueAndIndex(expression) | Create a unique index on a property |
DropIndexIfExists(expression) | Remove an index |
EnsureAddedAttributeIndexes() | Create indexes declared via [Indexed] attributes |
| Advanced | |
GetInternalConnection() | Return the raw SqliteConnection used by this collection |
Note: TryGetById returns FSharpOption<T> in C#. Use .get_IsSome() to check, then .Value to access. A miss returns None.
DBRef<T>
struct DBRef<T> : IEquatable, IComparable — A single reference to another entity. Stored as a foreign key, loaded on query.
DBRef<T>.To(long id) | Create a reference to an existing entity by ID |
DBRef<T>.From(T entity) | Create a reference for cascade-insert of a new entity |
DBRef<T>.None | Empty reference (no target) |
.Id | Database ID of the referenced entity |
.HasValue | true if the reference points to a target |
.Value | The loaded entity (throws if not loaded) |
.IsLoaded | true if the entity was populated by a query |
DBRef<TTarget, TId>
struct DBRef<TTarget, TId> : IEquatable, IComparable — Same as DBRef<T> but with a custom ID type.
DBRef<TTarget, TId>.To(TId id) | Create a reference by typed ID |
DBRef<TTarget, TId>.From(TTarget entity) | Create a typed-ID reference for cascade insert |
DBRef<TTarget, TId>.None | Empty typed-ID reference |
.Id | Database ID of the referenced entity (as long) |
.HasValue | true if the reference points to a target |
.Value | The loaded entity |
.IsLoaded | true if populated by a query |
DBRefMany<T>
class DBRefMany<T> : IList<T>, IReadOnlyList<T> — A one-to-many or many-to-many relation collection. Tracks mutations for persist on update.
new DBRefMany<T>() | Create an empty relation collection |
.Count | Number of items currently in the collection |
.IsLoaded | true if populated from a query |
.HasPendingMutations | true if in-memory changes are waiting to be written |
.WasCleared | true if the collection was cleared since load |
Add(T item) | Add an item to the relation |
Remove(T item) | Remove an item from the relation |
RemoveAt(int index) | Remove the item at a given index |
Insert(int index, T item) | Insert an item at a given index |
Clear() | Remove all items from the relation |
Contains(T item) | Check if an item is in the collection |
IndexOf(T item) | Get the index of an item |
CopyTo(T[] array, int arrayIndex) | Copy to an array |
this[int index] | Get or set by index |
DBRefMany<TTarget, TId>
class DBRefMany<TTarget, TId> : IList<TTarget>, IReadOnlyList<TTarget> — Same as DBRefMany<T> but for entities with a custom ID type.
new DBRefMany<TTarget, TId>() | Create an empty typed-ID relation collection |
Same members as DBRefMany<T> — Count, IsLoaded, HasPendingMutations, WasCleared, Add, Remove, Clear, indexer, etc. | |
DeletePolicy (enum)
Controls what happens to relations when entities are deleted. Used via [SoloRef] attribute.
Restrict | Block the operation if references exist (default for OnDelete) |
Cascade | Cascade delete to referencing entities (OnDelete only) |
Unlink | Remove references; entities survive |
Deletion | Unlink then delete zero-reference targets (default for OnOwnerDelete) |
DBRefOrder (enum)
Controls load ordering for DBRefMany collections. Used via [SoloRef] attribute.
Undefined | Load order is not guaranteed (default) |
TargetId | Order loaded items by target ID |
Note: DBRefMany<T> is relation-backed state, not a detached in-memory list. See the Relations and Relation Queries sections for full behavior.
Attributes
[Indexed]
class IndexedAttribute : Attribute — Mark a property for automatic index creation.
[Indexed] | Create a non-unique index on the property |
[Indexed(unique: true)] | Create a unique index on the property |
Unique | Whether this attribute creates a unique index |
[SoloId]
class SoloId : IndexedAttribute — Mark a property as a custom ID with a custom generator. Inherits index creation from [Indexed].
[SoloId(typeof(MyGenerator))] | Mark as ID with a custom generator type |
IdGenerator | The generator Type used for this ID |
[SoloRef]
class SoloRefAttribute : Attribute — Configure relation behavior on DBRef and DBRefMany properties.
OnDelete | Delete policy when the referenced target is deleted (default: Restrict) |
OnOwnerDelete | Delete policy when the owning document is deleted (default: Deletion) |
Unique | Enforce a one-to-one relation constraint (for DBRef<T>) |
OrderBy | Load ordering for DBRefMany collections |
[Polymorphic]
class PolymorphicAttribute : Attribute — Mark a class for polymorphic serialization. SoloDB stores a $type discriminator so derived types round-trip correctly.
[Polymorphic] | Enable polymorphic serialization on a class |
Note: In polymorphic queries, OfType<T>() matches the exact stored $type discriminator. It does not automatically include derived rows unless they were stored with that exact discriminator.
Events
ISoloDBCollectionEvents<T>
interface ISoloDBCollectionEvents<T> — Event registration API, inherited by ISoloDBCollection<T>. Register handlers on collection instances.
OnInserting(InsertingHandler<T>) | Register a before-insert handler |
OnInserted(InsertedHandler<T>) | Register an after-insert handler |
OnUpdating(UpdatingHandler<T>) | Register a before-update handler |
OnUpdated(UpdatedHandler<T>) | Register an after-update handler |
OnDeleting(DeletingHandler<T>) | Register a before-delete handler |
OnDeleted(DeletedHandler<T>) | Register an after-delete handler |
Unregister(handler) | Remove a previously registered handler (any type) |
Handler Delegates
All event handlers return SoloDBEventsResult.
InsertingHandler<T> | Before-insert: receives ISoloDBItemEventContext<T> |
InsertedHandler<T> | After-insert: receives ISoloDBItemEventContext<T> |
UpdatingHandler<T> | Before-update: receives ISoloDBUpdatingEventContext<T> |
UpdatedHandler<T> | After-update: receives ISoloDBUpdatingEventContext<T> |
DeletingHandler<T> | Before-delete: receives ISoloDBItemEventContext<T> |
DeletedHandler<T> | After-delete: receives ISoloDBItemEventContext<T> |
Event Contexts
ISoloDBItemEventContext<T> : ISoloDB — Context for insert and delete events | |
.Item | The item being inserted or deleted |
.CollectionName | Name of the collection that triggered the event |
ISoloDBUpdatingEventContext<T> : ISoloDB — Context for update events | |
.Item | The item state after the update |
.OldItem | The item state before the update |
.CollectionName | Name of the collection that triggered the event |
Note: Both event contexts implement ISoloDB, giving you full database access inside handlers (including FileSystem and other collections).
SoloDBEventsResult
class SoloDBEventsResult — Return value from event handlers.
SoloDBEventsResult.EventHandled | Handler completed; keep it registered for future events |
SoloDBEventsResult.RemoveHandler | Handler completed; unregister it automatically |
.IsEventHandled | Check if this is EventHandled |
.IsRemoveHandler | Check if this is RemoveHandler |
Query and Relation Extensions
RelationQueryExt
Extension methods for loading and excluding relations in LINQ queries.
Include(selector) | Load one relation path eagerly |
ThenInclude(selector) | Continue an include chain through a loaded relation |
ThenExclude(selector) | Exclude a child relation under an include chain |
Exclude(selector) | Leave one relation unloaded (keeps its ID) |
Exclude() | Load no relations unless explicitly included |
Note: Include/Exclude only change which relations are loaded. They do not affect which predicates SoloDB can translate. See Relation Queries for full behavior.
IIncludableQueryable<T, TProperty>
interface IIncludableQueryable<T, TProperty> : IQueryable<T> — Returned by Include and ThenInclude to enable chaining.
Extensions (static class)
General LINQ and update helper extension methods usable inside queries and UpdateMany transforms.
| Query Helpers | |
Like(string pattern) | Translate to SQLite LIKE in a LINQ predicate |
Any(predicate) | Translate a collection predicate to EXISTS subquery |
Dyn<T>(string propertyName) | Read a property dynamically inside a query |
Dyn<T>(PropertyInfo property) | Read a property dynamically from a PropertyInfo |
Dyn(string propertyName) | Read a property dynamically as object |
CastTo<T>() | Cast an object inside a query expression |
Items() | Materialize an IGrouping<TKey, T> into an array |
Update Transform Helpers (for use inside UpdateMany) | |
Set(value) | Replace a scalar, reference, or object member |
Append(value) | Append a value to an array or collection member |
SetAt(int index, value) | Replace one element inside an array-like member |
RemoveAt(int index) | Remove one element from an array-like member |
File Storage
IFileSystem
interface IFileSystem — Full file-system API inside the database. Access via db.FileSystem.
| Upload / Download | |
Upload(string path, Stream stream) | Write a file from a stream |
UploadAsync(string path, Stream stream) | Async file upload |
UploadBulk(IEnumerable<BulkFileData> files) | Write many files in one call |
Download(string path, Stream stream) | Stream a stored file out |
DownloadAsync(string path, Stream stream) | Async file download |
ReplaceAsyncWithinTransaction(string path, Stream stream) | Replace file contents inside the current transaction |
| Random Access | |
ReadAt(string path, long offset, int len) | Read a byte range without loading the whole file |
WriteAt(string path, long offset, byte[] data, bool createIfInexistent) | Write bytes at an offset |
WriteAt(string path, long offset, Stream data, bool createIfInexistent) | Write a stream at an offset |
| Open Streams | |
Open(SoloDBFileHeader file) | Open a file header as a DbFileStream |
OpenAt(string path) | Open a file by path as a DbFileStream |
TryOpenAt(string path) | Open a file by path, returns FSharpOption<DbFileStream> |
OpenOrCreateAt(string path) | Open or create a file stream at a path |
| File Headers | |
GetAt(string path) | Get a file header by path (throws if missing) |
TryGetAt(string path) | Get a file header by path as FSharpOption |
GetOrCreateAt(string path) | Get or create a file header |
Exists(string path) | Check whether a file or directory entry exists |
| Directory Headers | |
GetDirAt(string path) | Get a directory header by path |
TryGetDirAt(string path) | Get a directory header as FSharpOption |
GetOrCreateDirAt(string path) | Get or create a directory header |
| Metadata | |
SetMetadata(SoloDBFileHeader file, string key, string value) | Set metadata on a file by header |
SetMetadata(string path, string key, string value) | Set metadata on a file by path |
DeleteMetadata(SoloDBFileHeader file, string key) | Delete metadata from a file by header |
DeleteMetadata(string path, string key) | Delete metadata from a file by path |
SetDirectoryMetadata(SoloDBDirectoryHeader dir, string key, string value) | Set metadata on a directory by header |
SetDirectoryMetadata(string path, string key, string value) | Set metadata on a directory by path |
DeleteDirectoryMetadata(SoloDBDirectoryHeader dir, string key) | Delete metadata from a directory by header |
DeleteDirectoryMetadata(string path, string key) | Delete metadata from a directory by path |
| Timestamps | |
SetFileModificationDate(string path, DateTimeOffset date) | Set a file's modification time |
SetFileCreationDate(string path, DateTimeOffset date) | Set a file's creation time |
| Delete | |
Delete(SoloDBFileHeader file) | Delete a file by header |
Delete(SoloDBDirectoryHeader dir) | Delete a directory by header |
DeleteFileAt(string path) | Delete a file by path |
DeleteDirAt(string path) | Delete a directory by path |
| Listing | |
ListFilesAt(string path) | List files under a directory |
ListDirectoriesAt(string path) | List directories under a directory |
ListFilesAtPaginated(path, sortBy, sortDir, limit, offset) | Paginated file listing with total count |
ListDirectoriesAtPaginated(path, sortBy, sortDir, limit, offset) | Paginated directory listing with total count |
ListEntriesAtPaginated(path, sortBy, sortDir, limit, offset) | Paginated mixed listing with file and directory totals |
RecursiveListEntriesAt(string path) | Eager recursive listing of all entries |
RecursiveListEntriesAtLazy(string path) | Lazy recursive listing (streamed) |
| Move | |
MoveFile(string from, string toPath) | Move a file |
MoveReplaceFile(string from, string toPath) | Move a file, replacing the target if it exists |
MoveDirectory(string from, string toPath) | Move a directory |
| Copy | |
CopyFile(string fromPath, string toPath, bool copyMetadata = false) | Copy a file |
CopyFileAsync(string fromPath, string toPath, bool copyMetadata = false) | Async file copy |
CopyReplaceFile(string fromPath, string toPath, bool copyMetadata = false) | Copy a file, replacing the target if it exists |
CopyReplaceFileAsync(string fromPath, string toPath, bool copyMetadata = false) | Async copy-replace for a file |
CopyDirectory(string fromPath, string toPath, bool recursive = true, bool copyMetadata = false) | Copy a directory |
CopyDirectoryAsync(string fromPath, string toPath, bool recursive = true, bool copyMetadata = false) | Async directory copy |
CopyReplaceDirectory(string fromPath, string toPath, bool recursive = true, bool copyMetadata = false) | Copy a directory, replacing the target |
CopyReplaceDirectoryAsync(string fromPath, string toPath, bool recursive = true, bool copyMetadata = false) | Async copy-replace for a directory |
Note: SoloDB normalizes slash direction and adds a leading / if omitted. Names are case-sensitive. Unicode names are supported. File data is stored in 16KB chunks.
DbFileStream
class DbFileStream : Stream — A seekable, readable, writable stream backed by the database. Returned by Open/OpenAt/OpenOrCreateAt.
FullPath | The full path of the file in the file system |
CanRead | Always true |
CanSeek | Always true |
CanWrite | Always true |
Length | File size in bytes |
Position | Current stream position |
Read(byte[], int, int) | Read bytes into a buffer |
Read(Span<byte>) | Read bytes into a span |
Write(byte[], int, int) | Write bytes from a buffer |
Write(ReadOnlySpan<byte>) | Write bytes from a span |
WriteAsync(byte[], int, int, CancellationToken) | Async write |
Seek(long offset, SeekOrigin origin) | Seek to a position |
SetLength(long value) | Set the stream length (truncate or extend) |
Flush() | Flush pending writes |
File Storage Data Types
SoloDBFileHeader
class SoloDBFileHeader — Metadata header for a stored file.
Id | File ID (long) |
Name | File name |
FullPath | Full file path |
DirectoryId | Parent directory ID |
Length | File size in bytes |
Created | Creation time (DateTimeOffset) |
Modified | Modification time (DateTimeOffset) |
Metadata | Key-value metadata dictionary (IReadOnlyDictionary<string, string>) |
SoloDBDirectoryHeader
class SoloDBDirectoryHeader — Metadata header for a stored directory.
Id | Directory ID (long) |
Name | Directory name |
FullPath | Full directory path |
ParentId | Parent directory ID or null for root |
Created | Creation time |
Modified | Modification time |
Metadata | Key-value metadata dictionary |
SoloDBEntryHeader
struct SoloDBEntryHeader — Discriminated union: either a file or a directory. Returned by recursive listing methods.
IsFile | true if this entry is a file |
IsDirectory | true if this entry is a directory |
file | The SoloDBFileHeader (when IsFile) |
directory | The SoloDBDirectoryHeader (when IsDirectory) |
Name | Entry name |
FullPath | Entry full path |
DirectoryId | Parent directory ID |
Created | Entry creation time |
Modified | Entry modification time |
Metadata | Entry metadata dictionary |
static NewFile(SoloDBFileHeader) | Create a file entry |
static NewDirectory(SoloDBDirectoryHeader) | Create a directory entry |
BulkFileData
class BulkFileData — Input record for UploadBulk.
FullPath | Destination path for the file |
Data | File content as byte[] |
Created | Optional creation timestamp |
Modified | Optional modification timestamp |
SortField / SortDirection (enums)
Used by paginated listing methods.
SortField.Name | Sort by name |
SortField.Size | Sort by file size |
SortField.Created | Sort by creation time |
SortField.Modified | Sort by modification time |
SortDirection.Ascending | Ascending order |
SortDirection.Descending | Descending order |
ID Generation
IIdGenerator
interface IIdGenerator — Untyped custom ID generator. Implement this and pass the type to [SoloId].
IsEmpty(object id) | Return true if the ID value is empty (needs generation) |
GenerateId(object collection, object item) | Generate a new ID for the item |
IIdGenerator<T>
interface IIdGenerator<T> — Typed custom ID generator. Provides type-safe access to the collection and item.
IsEmpty(object id) | Return true if the ID value is empty |
GenerateId(ISoloDBCollection<T> collection, T item) | Generate a new ID with typed collection and item |
Serialization
JsonValue
class JsonValue : IDynamicMetaObjectProvider, IComparable, IEquatable — SoloDB's internal JSON representation. Used by untyped collections and for manual serialization.
| Creation | |
JsonValue.New() | Create an empty JSON object |
JsonValue.New(IEnumerable<KeyValuePair<string, object>>) | Create a JSON object from key-value pairs |
JsonValue.Null | The JSON null value |
JsonValue.NewString(string) | Create a JSON string |
JsonValue.NewNumber(decimal) | Create a JSON number |
JsonValue.NewBoolean(bool) | Create a JSON boolean |
JsonValue.NewList(IList<JsonValue>) | Create a JSON array |
JsonValue.NewObject(IDictionary<string, JsonValue>) | Create a JSON object from a dictionary |
| Serialization | |
JsonValue.Serialize<T>(T value) | Serialize any object to JsonValue |
JsonValue.SerializeWithType<T>(T value) | Serialize with $type discriminator |
JsonValue.Parse(string json) | Parse a JSON string |
JsonValue.Parse(ReadOnlySpan<char>) | Parse from a char span |
JsonValue.Parse(ReadOnlySpan<byte>) | Parse from a UTF-8 byte span |
JsonValue.ParseInto<T>(string json) | Parse JSON directly into a typed object |
JsonValue.ParseInto<T>(ReadOnlySpan<char>) | Parse char span into a typed object |
JsonValue.ParseInto<T>(ReadOnlySpan<byte>) | Parse UTF-8 span into a typed object |
| Deserialization | |
ToObject<T>() | Convert this JsonValue to a typed object |
ToObject(Type targetType) | Convert to a specified type |
ToJsonString() | Render as a JSON string |
| Property Access | |
this[string name] | Get or set a property by name |
GetProperty(string name) | Get a property value |
SetProperty(string name, JsonValue value) | Set a property value |
TryGetProperty(string name, out JsonValue v) | Try to get a property |
Contains(string name) | Check if a property exists |
| Type Checks | |
JsonType | The JsonValueType of this value |
IsNull | true if null |
IsString | true if string |
IsNumber | true if number |
IsBoolean | true if boolean |
IsList | true if array |
IsObject | true if object |
JsonValueType (enum)
Null | JSON null |
String | JSON string |
Number | JSON number |
Boolean | JSON boolean |
List | JSON array |
Object | JSON object |
JsonValue Subtypes
Typed subclasses of JsonValue for each JSON kind. You rarely construct these directly; use the NewXxx factory methods.
JsonValue.String | Holds a string Item |
JsonValue.Number | Holds a decimal Item |
JsonValue.Boolean | Holds a bool Item |
JsonValue.List | Holds an IList<JsonValue> Item |
JsonValue.Object | Holds an IDictionary<string, JsonValue> Item |
Untyped Collection Extensions
UntypedCollectionExt
Extension methods for ISoloDBCollection<JsonValue> to insert CLR objects without a compile-time type.
InsertObj(object value) | Serialize an object to JsonValue and insert it |
InsertBatchObj(IEnumerable<object> values) | Batch insert untyped objects |
MongoDB Compatibility Layer
Drop-in compatibility types in SoloDatabase.MongoDB namespace for projects migrating from MongoDB. These wrap SoloDB's native API with MongoDB-like naming.
MongoClient
class MongoClient : IDisposable
new MongoClient(string source) | Create a client (same source format as SoloDB) |
GetDatabase(string name) | Get a MongoDatabase handle (name is optional) |
MongoDatabase
class MongoDatabase : IDisposable
GetCollection<T>(string name) | Get a typed collection by name |
CreateCollection(string name) | Create an untyped collection |
ListCollections() | List collection names |
Dispose() | Close the database |
CollectionExtensions
MongoDB-style extension methods on ISoloDBCollection<T>.
InsertOne(collection, document) | Insert a single document, returns ID |
InsertMany(collection, documents) | Insert multiple documents, returns InsertManyResult |
Find(collection, filter) | Query with a filter expression, returns IQueryable<T> |
CountDocuments(collection) | Count all documents |
CountDocuments(collection, filter) | Count matching documents |
DeleteOne(collection, filter) | Delete the first match |
DeleteMany(collection, filter) | Delete all matches |
ReplaceOne(collection, filter, document) | Replace the first match |
ReplaceMany(collection, filter, document) | Replace all matches |
BsonDocument
class BsonDocument : IDynamicMetaObjectProvider — MongoDB-compatible document wrapper around JsonValue.
new BsonDocument() | Create an empty document |
new BsonDocument(JsonValue json) | Wrap an existing JsonValue |
new BsonDocument(object obj) | Serialize an object into a document |
Json | The underlying JsonValue |
this[string key] | Get or set a property as BsonDocument |
Add(string key, object value) | Add a property |
Remove(string key) | Remove a property |
Contains(object item) | Check if a value exists |
GetValue(string key) | Get a property as JsonValue |
ToObject<T>() | Deserialize to a typed object |
ToJsonString() | Render as JSON |
static Deserialize(string json) | Parse a JSON string into a BsonDocument |
| Type Conversions | |
AsString / IsString | String access and check |
AsInt32 / IsInt32 | Int32 access and check |
AsInt64 / IsInt64 | Int64 access and check |
AsDouble / IsDouble | Double access and check |
AsDecimal | Decimal access |
AsBoolean / IsBoolean | Boolean access and check |
AsBsonDocument / IsBsonDocument | Nested document access and check |
AsBsonArray / IsBsonArray | Array access and check |
ToInt32() / ToInt64() / ToDouble() / ToDecimal() / ToBoolean() | Explicit type conversion methods |
FilterDefinitionBuilder<T>
MongoDB-style filter builder. Chain calls then Build() to get a LINQ expression.
Empty | A filter that matches all documents |
Eq(field, value) | Equality filter (by expression or string) |
Gt(field, value) | Greater-than filter |
Lt(field, value) | Less-than filter |
In(field, values) | Membership filter |
Build() | Produce the final Expression<Func<T, bool>> |
QueryDefinitionBuilder<T>
Same as FilterDefinitionBuilder<T> with uppercase method names (EQ, GT, LT, IN).
Empty | A query that matches all documents |
EQ / GT / LT / IN | Same semantics as FilterDefinitionBuilder |
Build() | Produce the final Expression<Func<T, bool>> |
Builders<T>
Entry point for MongoDB-style builders.
Filter | Get a FilterDefinitionBuilder<T> |
Query | Get a QueryDefinitionBuilder<T> |
InsertManyResult
class InsertManyResult — Result of a batch insert via the MongoDB compatibility layer.
Ids | List of inserted IDs |
Count | Number of inserted documents |
Internal Marker Types
These static class types exist in the public API but contain no user-facing members. They serve as containers for nested types or extension methods.
FileStorage | Static class; file-storage implementation container |
FileStorageCoreStream | Static class; parent of DbFileStream |
SoloDBEventsResult.Tags | F# discriminated-union tag constants (EventHandled = 0, RemoveHandler = 1) |
SoloDBEntryHeader.Tags | F# discriminated-union tag constants (File = 0, Directory = 1) |
JsonValue.Tags | F# discriminated-union tag constants for JSON value types |