MilkStore 0.3.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package MilkStore --version 0.3.0
                    
NuGet\Install-Package MilkStore -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="MilkStore" Version="0.3.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="MilkStore" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="MilkStore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add MilkStore --version 0.3.0
                    
#r "nuget: MilkStore, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package MilkStore@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=MilkStore&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=MilkStore&version=0.3.0
                    
Install as a Cake Tool

MilkStore

A lightweight micro-ORM for .NET with a static, string-free API and support for PostgreSQL, SQL Server and SQLite.

  • Entities are plain classes; tables are created and extended automatically.
  • Insert and update take anonymous objects.
  • Where takes a lambda and is translated to SQL.
  • Interpolated SQL is always parameterised.
  • Every operation has an async form.
  • No dependency on any database driver; install only the one you use.

Installation

dotnet add package MilkStore

# Then the provider(s) you need:
dotnet add package Npgsql                    # PostgreSQL
dotnet add package Microsoft.Data.SqlClient  # SQL Server
dotnet add package Microsoft.Data.Sqlite     # SQLite

Targets .NET 10.

Quick start

using MilkStore;

PGStore.Connect.Host("localhost").Database("mydb").User("user").Password("pass");

public class User : PGStoreObject<User>
{
    [Identity] public int UserID { get; set; }

    [Required, MaxLength(100)]
    public string Name { get; set; } = "";

    public string? Email { get; set; }
    public Tier    Tier  { get; set; }
}
var user = User.Insert(new { Name = "Alice", Email = "alice@example.com" });

user.Update(new { Name = "Alice Smith" });

var found  = User.FindById(user.UserID);
var admins = User.Where(u => u.Name.Contains("Admin") && u.Tier == Tier.Pro);

user.Delete();

Derive from PGStoreObject<T>, MSSQLStoreObject<T> or SQLiteStoreObject<T> to bind an entity to a store. The table is created the first time the entity is used.

Entities

Attribute Effect
[Identity] Primary key. Populated on insert, and used in the WHERE of Update and Delete
[Column("name")] Column name, when it differs from the property name
[Table("name")] Table name, when it differs from the class name
[NotMapped] Exclude the property from the database
[MaxLength(n)] VARCHAR(n) / NVARCHAR(n) instead of an unbounded text column
[Required] NOT NULL
[SOUpdate] Also include this column in the WHERE of Update, for optimistic concurrency

Supported property types: bool, char, every integer type, decimal, double, float, string, Guid, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, byte[], any enum, and the Nullable<T> form of any of these.

Reading

User.FindById(id)                         // one row by identity, or null
User.All()                                // every row
User.Where(u => u.Active)                 // rows matching a predicate
User.First(u => u.Email == address)       // first match, or null

Store<PGStore>.Count<User>(u => u.Active) // COUNT(*)
Store<PGStore>.Any<User>(u => u.Active)   // COUNT(*) > 0

Count and Any are on Store<TStore> rather than on the entity, because an entity property named Count would shadow a static method of the same name.

Predicates

User.Where(u => u.Name == name)
User.Where(u => u.Active && u.Age >= 18)
User.Where(u => u.Score == null)                    // IS NULL
User.Where(u => u.Name.StartsWith("Ad"))            // LIKE 'Ad%'
User.Where(u => tiers.Contains(u.Tier))             // IN (...)
User.Where(u => u.Joined > DateTime.UtcNow.AddDays(-7))

Supported: == != < <= > >=, && || !, comparison with null, .HasValue, bare bool columns, string.Contains / StartsWith / EndsWith / Equals, and Contains over a collection.

Anything else throws NotSupportedException naming the subexpression. Predicates are never evaluated in memory, so a query either runs in the database or fails loudly.

User.Where(u => u.Name.Length > 3)   // throws: Length is not a column

Ordering and paging

Ordering and paging are applied by the database.

User.All(u => u.Name)                                      // ORDER BY Name
User.All(u => u.Joined, descending: true, take: 20)        // newest 20
User.Where(u => u.Active, u => u.Name, take: 20, skip: 40) // third page of 20
User.First(u => u.Active, u => u.Joined, descending: true) // newest match

orderBy must be a single mapped column; anything else throws.

take or skip without an orderBy returns that many rows, but which rows is undefined and varies by database. Pass an ordering whenever the result must be stable or pages must line up.

Writing

var user = User.Insert(new { Name = "Alice" });   // returns the entity, identity populated

user.Update(new { Name = "Alice Smith" });        // by identity
user.Delete();                                    // by identity

Columns omitted from an Insert take the column's default.

Many rows

User.InsertMany(people.Select(p => new { p.Name, p.Email }));

Sends multi-row INSERT statements, split to stay under the dialect's parameter limit, inside a transaction of its own. Identity values are not read back; use Insert when you need them.

Many rows matching a predicate

User.Delete(u => u.Joined < cutoff);
User.Update(u => u.Joined < cutoff, new { Active = false });

Each sends a single statement.

Transactions

PGStore.Transaction(() =>
{
    from.Update(new { Balance = from.Balance - 10 });
    to.Update(new { Balance = to.Balance + 10 });
});

Transaction commits when the work returns and rolls back if it throws. The exception propagates unchanged. An overload returns a value:

var total = PGStore.Transaction(() => Store<PGStore>.Count<User>(u => u.Active));

To control the outcome yourself, open a scope:

using var tx = PGStore.Begin();

foreach (var row in rows)
    User.Insert(new { row.Name });

tx.Commit();

Leaving the scope without committing rolls back. Both forms require a using, or the connection is held until garbage collection.

The scope is ambient: every operation on that store joins it, so nothing takes a transaction parameter. Nesting joins the outer scope rather than opening a second connection, and a nested Rollback() prevents the outer scope committing. Work after Commit() runs outside the scope.

If a rollback fails — because the server already ended the transaction, or the connection dropped — the failure is suppressed and the connection is returned to the pool regardless.

Store<T>.InTransaction reports whether the caller is already inside a scope.

Async

Every operation has an async form taking an optional CancellationToken.

var user  = await User.InsertAsync(new { Name = "Ada" });
var found = await User.FindByIdAsync(user.UserID);
var page  = await User.WhereAsync(u => u.Active, u => u.Name, take: 20);
var first = await User.FirstAsync(u => u.Active);
var all   = await User.AllAsync();

await User.InsertManyAsync(rows.Select(r => new { r.Name }));
await User.UpdateAsync(u => u.Stale, new { Active = false });
await User.DeleteAsync(u => u.Stale);

await user.UpdateAsync(new { Name = "Ada L." });
await user.DeleteAsync();

await Store<PGStore>.CountAsync<User>(u => u.Active);
await Store<PGStore>.AnyAsync<User>(u => u.Active);

Transactions take the work:

await PGStore.TransactionAsync(async () =>
{
    await from.UpdateAsync(new { Balance = from.Balance - 10 });
    await to.UpdateAsync(new { Balance = to.Balance + 10 });
});

var total = await PGStore.TransactionAsync(async () => await Store<PGStore>.CountAsync<User>(u => u.Active));

There is no BeginAsync. A transaction scope must be established in the frame that runs the work, so the async form takes a delegate rather than returning a scope.

Awaiting inside a scope is supported, including when the continuation resumes on another thread.

Microsoft.Data.Sqlite implements its async methods synchronously. The async API returns correct results on SQLite but performs no asynchronous I/O there; PostgreSQL and SQL Server do.

Threads

Without a transaction, use MilkStore from as many threads as you like. Every statement takes its own pooled connection, so parallel readers, parallel writers and parallel InsertMany are all safe, and identity values are per-connection and cannot cross between threads.

A transaction scope runs one statement at a time. It holds a single connection, and no driver permits two statements at once on one connection. The scope flows into Task.Run and Parallel.For, so work started inside a scope joins it:

using var tx = MSSQLStore.Begin();

Parallel.ForEach(rows, row => User.Insert(new { row.Name }));   // throws

tx.Commit();

Use one of these instead:

User.InsertMany(rows.Select(r => new { r.Name }));

Parallel.ForEach(rows, row =>
{
    using var tx = MSSQLStore.Begin();
    User.Insert(new { row.Name });
    tx.Commit();
});

Overlapping statements on one scope throw InvalidOperationException. Sequential use across threads — including an await that resumes elsewhere — is allowed.

Raw SQL

Query takes an interpolated string and binds every hole as a parameter.

User.Query($"""SELECT * FROM "User" WHERE "Name" = {name} AND "Tier" = {Tier.Pro}""")
// SELECT * FROM "User" WHERE "Name" = @0 AND "Tier" = @1

A hole is always a parameter and can never become SQL text. The parameter is an interpolated string handler, so this is enforced at compile time.

Method Returns
Entity.Query($"...") List<Entity>
Store.Execute($"...") rows affected
Store.ExecuteScalar($"...") the first column of the first row
Store.Is($"...") whether a row came back

Each has a *Raw counterpart — QueryRaw, ExecuteRaw, ExecuteScalarRaw, IsRaw — taking SQL verbatim, with no parameterisation. Async forms: QueryAsync, QueryRawAsync, ExecuteAsync, ExecuteRawAsync, ExecuteScalarAsync, ExecuteScalarRawAsync.

Identifiers

Quote identifiers in the SQL text. Tables are created with quoted names, so an unquoted identifier will not match on PostgreSQL, and User is a reserved word on several dialects. A raw string literal keeps this readable:

User.Query($"""SELECT * FROM "User" WHERE "Name" LIKE {pattern}""")

A hole cannot be an identifier — no database accepts a parameter where a table or column name belongs, so this compiles and then fails at runtime:

User.Query($"""SELECT * FROM {"User"}""")   // becomes: SELECT * FROM @0

For an identifier that is not known until runtime, Sql.Id validates and quotes it for the target dialect ("Name" on PostgreSQL and SQLite, [Name] on SQL Server):

User.Query($"SELECT * FROM {User.Table} ORDER BY {Sql.Id(sortColumn)} DESC")

Sql.Id accepts only letters, digits, underscore and ., and throws otherwise, so it is safe with untrusted input. Entity.Table is the entity's own table name.

Sql.Raw splices a fragment verbatim, for things that are neither values nor identifiers:

User.Query($"SELECT * FROM {User.Table} ORDER BY {Sql.Id(col)} {Sql.Raw(direction)}")

Sql.Raw is unchecked. Never build one from user input.

Connections

Each store carries a connection builder.

SQLiteStore.Connect.File("app.db");
PGStore.Connect.Host("localhost").Database("mydb").User("u").Password("p");
MSSQLStore.Connect.Server(".").Database("mydb").Trusted();

Set applies any keyword the fluent surface does not cover, and returns the same builder, so it composes with the rest:

MSSQLStore.Connect.Server("db").Set("Packet Size", 8192).Database("app").Trusted();

Raw replaces the whole string and discards every default. Assigning ConnectionString is the same as calling Raw.

PGStore.Connect.Raw("Host=...;Username=...");
PGStore.ConnectionString = "Host=...;Username=...";

Has("Encrypt") reports whether a keyword is set, including inside a Raw string. IsRaw reports whether a raw string is in use.

These keywords are set unless you override them:

Setting Stores
Pooling=True all
Max Auto Prepare=20, Auto Prepare Min Usages=2, Enlist=False PostgreSQL
MultipleActiveResultSets=False, Application Name=MilkStore SQL Server
Foreign Keys=True SQLite
journal_mode=WAL SQLite, applied once to the database file

Turn WAL off with SQLiteStore.Connect.Wal(false).

Encryption (SQL Server)

MSSQLStore.Connect.Encrypt(true);                      // Mandatory
MSSQLStore.Connect.Encrypt("Strict");                  // TDS 8.0
MSSQLStore.Connect.TrustCertificate();                 // accept a self-signed certificate
MSSQLStore.Connect.CertificateHostName("db.internal"); // validate against a different name

Microsoft.Data.SqlClient encrypts by default, so a server with a self-signed certificate fails until you call TrustCertificate(). Encrypt("Strict") ignores TrustCertificate and requires a certificate that validates; use CertificateHostName when connecting by IP or through a load balancer.

Server(host, port) pins the connection to TCP:

MSSQLStore.Connect.Server("10.0.0.5", 1433).Database("app");

Given a bare host name, SqlClient may fall back to Named Pipes when TCP does not answer and report a Named Pipes error, which obscures the real cause. Prefer the port overload for any server that is not local.

Pool warming

Connection pooling is left to the provider. Pools start empty, so the first query otherwise pays for the full connection handshake.

Three connections are opened on a background task as soon as the connection settings are set.

Store<PGStore>.AutoWarm = 0;    // disable
Store<PGStore>.AutoWarm = 16;   // open more
PGStore.Warm(4);                // open some now, synchronously

AutoWarm is capped by the pool's maximum size, leaving one connection free for the caller.

Defining a store

The built-in stores are ordinary types. Declare your own to configure a database in code, or to use several databases at once:

public class Reporting : SelfLoadingStore<Reporting>, IStore, IProviderInfo
{
    public static SqliteOptions Connect { get; } = new SqliteOptions().File("reports.db");

    static Reporting() => Store<Reporting>.Warms(Connect);

    public static string DatabaseName { get; set; } = "reporting";

    public static string ConnectionString
    {
        get => Connect.ToString();
        set => Connect.Raw(value);
    }

    public static ISqlSyntax Syntax => ISqlSyntax.SQLite;

    public static string Package    => "Microsoft.Data.Sqlite";
    public static string Connection => $"Microsoft.Data.Sqlite.SqliteConnection, {Package}";
}

public class Metric : StoreObject<Reporting, Metric>
{
    [Identity] public int    MetricID { get; set; }
    public            string Label    { get; set; }
}

Stores are independent: separate connections, separate pools and separate ambient transactions.

Store<T>.Warms(Connect) opts the store into pool warming. It must be called from a static constructor, so that Connect is assigned by the time it runs.

Logging

Logging is off by default and costs one null check per statement while off.

Log.ToConsole();                              // every statement
Log.Slow(TimeSpan.FromMilliseconds(50));      // only statements at or over a threshold
Log.On = q => logger.LogDebug("{Query}", q);  // any other sink
Log.Off();

A Query carries the store name, SQL text, elapsed time, rows affected, and whether it ran inside a transaction.

Log.Collect() aggregates instead of reporting each statement, grouping by SQL text:

using var stats = Log.Collect();

DoSomeWork();

Console.Write(stats.Report());
233 statements, 59.7 ms total
 count   total ms    avg ms    max ms  statement
   200       59.0      0.30      3.10  INSERT INTO "Person" ("Name", "Age") VALUES (@p0, @p1) ...
    30        0.1      0.00      0.02  SELECT "PersonID", "Name", "Age" FROM "Person" WHERE "PersonID" = @id

* 200 separate INSERTs into the same table, 59 ms. Entity.InsertMany(rows) sends them as one
  statement, so the whole loop costs one round trip.
* the same SELECT ran 30 times, 0.11 ms, which usually means a query inside a loop. Fetch the
  rows once with Where(...) and match them up in memory.
* nothing ran inside a transaction. One scope around a batch of work saves a round trip and
  a disk flush per statement.

stats.Report(top) returns the table and the notes; stats.Advice() returns the notes alone. stats.Count and stats.Total are the totals. Disposing the collector restores the previous sink.

Schema generation

Tables are created the first time an entity is used, and properties added to an entity later are added to the table. Removed properties leave their columns alone.

  • [Identity] uses the dialect's auto-increment: SERIAL on PostgreSQL, IDENTITY(1,1) on SQL Server, the INTEGER PRIMARY KEY rowid alias on SQLite.
  • A non-nullable value-type column is NOT NULL DEFAULT default(T), so an Insert may omit it and the column can be added to a table that already has rows.
  • Enums map to their backing integral type. Nullable<T> maps to the same column type as T.

To execute a setting on every new connection, implement IStore.Configure(DbConnection) on the store.

Building

dotnet build
dotnet test
dotnet run --project samples/MilkStore.Sample

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.7.1 121 9/2/2026
0.6.0 102 9/2/2026
0.4.0 97 9/2/2026
0.3.0 96 9/2/2026
0.2.0 98 9/2/2026
0.1.0 108 9/2/2026