CodeLogic.MySQL2 4.2.3

There is a newer version of this package available.
See the version list below for details.
dotnet add package CodeLogic.MySQL2 --version 4.2.3
                    
NuGet\Install-Package CodeLogic.MySQL2 -Version 4.2.3
                    
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="CodeLogic.MySQL2" Version="4.2.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="CodeLogic.MySQL2" Version="4.2.3" />
                    
Directory.Packages.props
<PackageReference Include="CodeLogic.MySQL2" />
                    
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 CodeLogic.MySQL2 --version 4.2.3
                    
#r "nuget: CodeLogic.MySQL2, 4.2.3"
                    
#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 CodeLogic.MySQL2@4.2.3
                    
#: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=CodeLogic.MySQL2&version=4.2.3
                    
Install as a Cake Addin
#tool nuget:?package=CodeLogic.MySQL2&version=4.2.3
                    
Install as a Cake Tool

CodeLogic.MySQL2

NuGet

v4.0.0 — major rewrite. Typed LINQ translated to SQL, compiled row materializers, working result cache (time-quantized keys + table-version invalidation), SQL-side aggregation (GroupBy + aggregating Select), projection pushdown, covering indexes, attribute-driven retention. See the Performance docs for benchmark numbers.

MySQL / Percona / MariaDB library for CodeLogic. Typed LINQ-shaped queries translated to SQL, compiled row materializers, working result cache, server-side aggregation, and covering indexes driven by attributes. Built on MySqlConnector.

Install

dotnet add package CodeLogic.MySQL2

Quick start

await Libraries.LoadAsync<MySQL2Library>();

var mysql = Libraries.Get<MySQL2Library>();

// CRUD via the repository
var repo = mysql.GetRepository<UserRecord>();
await repo.InsertAsync(new UserRecord { Name = "Alice", Email = "alice@example.com" });

// Typed LINQ — translated to SQL
var activeAdmins = await mysql.Query<UserRecord>()
    .Where(u => u.IsActive && u.Role == "admin")
    .OrderBy(u => u.Name)
    .WithCache(TimeSpan.FromMinutes(5))
    .ToPagedListAsync(page: 1, pageSize: 20);

What's in the box

Typed query builder

Full expression translation to SQL — no magic strings in consumer code.

Capability Shape
Filter .Where(x => x.Status == "active" && x.Age >= 18)
Sort .OrderBy, .OrderByDescending, .ThenBy, .ThenByDescending
Paging .Take, .Skip, .ToPagedListAsync
String ops Contains / StartsWith / EndsWithLIKE (escaped)
IN list.Contains(x.Col)x.Col IN (...)
Null x.Col == nullIS NULL; string.IsNullOrEmpty(x.Col) too
Nullable x.NullableCol.Value passthrough

Projection pushdown

.Select<TResult>(x => new Foo(x.A, x.B)) emits a real column list — only the columns referenced are transferred from the DB, not SELECT *.

var lean = await mysql.Query<PostRecord>()
    .Where(p => p.PublishedUtc >= since)
    .Select(p => new { p.Id, p.Title, p.Slug })   // ships 3 columns, not 15
    .ToListAsync();

SQL aggregation (GroupBy → Select)

GroupBy + aggregating Select translate to real GROUP BY + SUM / AVG / COUNT / MIN / MAX / ANY on the server. No rows materialize client-side.

var heatmap = await mysql.Query<SnapshotRecord>()
    .Where(s => s.SnapshotUtc >= since)
    .GroupBy(s => new { Dow  = SqlFn.DayOfWeek(s.SnapshotUtc),
                        Hour = SqlFn.Hour(s.SnapshotUtc) })
    .Select(g => new HeatmapCell(
        g.Key.Dow,
        g.Key.Hour,
        g.Average(x => (double)x.PlayerCount)))
    .ToListAsync();

Inside Select(g => ...) you can call: g.Key, g.Key.Member, g.Sum(x => ...), g.Average(x => ...), g.Min, g.Max, g.Count(), g.Count(x => pred), g.Any(), g.Any(pred).

Ternary inside aggregates becomes CASE WHEN: g.Sum(x => x.IsOnline ? 1 : 0)SUM(CASE WHEN is_online THEN 1 ELSE 0 END).

SqlFn helpers

SQL function markers recognised by the translator (same pattern as EF.Functions):

Group Methods
Date/time Year, Month, Day, Hour, Minute, DayOfWeek (0=Sun..6=Sat), Date, BucketUtc(d, seconds)
Conditional Coalesce(...), IfNull(v, fallback)
String Lower, Upper, Concat(...), Like(s, pattern)
Math Round(v, digits), Floor, Ceiling

Result cache (now actually working)

.WithCache(TimeSpan.FromMinutes(5))

Two things that weren't right before and now are:

  1. DateTime closures near "now" are time-quantized — a .Where(x => x.At >= UtcNow.AddDays(-30)) predicate no longer produces a unique cache key per call. The window is configurable (CacheConfiguration.TimeQuantizeSeconds, default 60s).
  2. Table-version invalidation — mutations bump a per-table counter baked into the key. No eviction loop; old keys simply become un-hittable.

Cache hits and misses publish CacheHitEvent / CacheMissEvent on the CodeLogic event bus.

Smart cache pools — kept warm in the background (new in 4.2)

For pages where a small set of queries should stay hot regardless of read traffic, register a named pool with a refresh interval and opt queries into it. The pool's background timer re-runs every registered query and overwrites the cache entry — readers never block on the DB after the first populate.

// Declare the pool once at startup (typically in OnInitializeAsync).
mysql.RegisterCachePool("dashboard", refreshEvery: TimeSpan.FromSeconds(30));

// Opt queries into the pool. First call: cold DB hit, result cached, query
// registered with the pool. Subsequent calls: cache hit. Every 30s the
// pool's timer re-runs the query and refreshes the entry.
var top10 = await mysql.Query<PlayerRecord>()
    .Where(p => p.IsActive)
    .OrderByDescending(p => p.Score)
    .Take(10)
    .SmartCache("dashboard")
    .ToListAsync();

// Out-of-schedule refresh — useful right after a deploy to prime the cache.
await mysql.RefreshCachePoolAsync("dashboard");

// Warm the cache on startup — readers never see a cold pool. The warm-up
// callback just calls the queries that should be hot; they auto-register
// with the pool via .SmartCache("dashboard") as usual. Runs as a fire-and-
// forget task so startup doesn't block.
mysql.RegisterCachePool("dashboard", TimeSpan.FromSeconds(30),
    warmUp: async () =>
    {
        await statsService.GetDashboardTop10Async();
        await statsService.GetRecentMatchesAsync();
    });

// Diagnostic snapshot
foreach (var s in mysql.GetCachePoolStats())
    Console.WriteLine($"{s.Name}: {s.EntryCount} entries, {s.TicksFired} ticks fired");

How it behaves:

  • TTL is derived from the poolrefreshEvery * 2. Cache entries outlive a missed refresh by one cycle before falling back to cache-aside.
  • Bounded cardinality — an entry that has not been read for MaxIdleFires (default 3) consecutive ticks is dropped from the refresh list. Parameterized queries (per-user keys, etc.) don't spawn unbounded background work — they auto-retire when nobody's looking.
  • Mutually exclusive with .WithCache(TimeSpan) — if both are set, the pool wins.
  • Falls back gracefully — an unknown pool name on .SmartCache(name) logs a warning and the query runs uncached. No exception.
  • Skipped inside transactions — same rule as .WithCache.
  • Single-node only — coordination across multiple app instances is on the roadmap. With a Redis-backed ICacheStore today, every node will run its own refresh timer; that's safe but wasteful at high node counts.

Bulk writes / predicate mutations

// Real batched INSERT ... VALUES (...), (...), ... (configurable chunk size)
await repo.InsertManyAsync(rows);

// Typed bulk update — one UPDATE statement, values or column expressions
await mysql.Query<TicketRecord>()
    .Where(t => t.Status == "open" && t.CreatedUtc < cutoff)
    .UpdateAsync(t => new TicketRecord { Status = "stale", Counter = t.Counter + 1 });

// Bulk delete by predicate
await mysql.Query<SnapshotRecord>()
    .Where(s => s.SnapshotUtc < cutoff)
    .DeleteAsync();

Schema sync driven by attributes

Record classes are the source of truth. SyncTableAsync<T>() creates / alters MySQL tables to match.

[Table(Name = "servers_snapshot")]
[RetainDays(90, nameof(SnapshotUtc))]                    // daily background purge
[CompositeIndex("ix_server_snapshot", "server_id", "snapshot_utc")]
public sealed class SnapshotRecord
{
    [Column(DataType = DataType.BigInt, Primary = true, AutoIncrement = true)]
    public long Id { get; set; }

    [Column(Name = "server_id", DataType = DataType.BigInt, NotNull = true)]
    public long ServerId { get; set; }

    [Column(Name = "snapshot_utc", DataType = DataType.DateTime, NotNull = true)]
    [Index(Name = "ix_snapshot_utc_covering",
           Include = new[] { nameof(ServerId), nameof(PlayerCount) })]  // covering index
    public DateTime SnapshotUtc { get; set; }

    [Column(Name = "player_count", DataType = DataType.Int, NotNull = true)]
    public int PlayerCount { get; set; }
}

Attributes supported:

Attribute Purpose
[Table] table name, engine, charset, collation, comment
[Column] type, size, nullability, default, PK/AI/Unique/Index
[Index] new — named, unique, covering (Include)
[CompositeIndex] multi-column named index on the class
[ForeignKey] FK with ON DELETE/UPDATE actions
[RetainDays] new — background purge job for time-series tables
[Ignore] skip property for schema / read / write

Observability

The library publishes events to CodeLogic's event bus:

  • QueryExecutedEvent — every query: SQL, elapsed ms, row count, cache hit flag
  • SlowQueryEvent — queries over the threshold (logged too)
  • CacheHitEvent / CacheMissEvent — per-call
  • N1QueryDetectedEvent — when the detector trips
  • TableSyncedEvent, DatabaseConnected / Disconnected — lifecycle

Background retention worker

Entities with [RetainDays(N, nameof(TimestampCol))] are picked up automatically when registered via SyncTableAsync<T>(). A daily pass runs batched DELETE WHERE {col} < NOW() - INTERVAL N DAY LIMIT batchSize until drained.

Configuration

Two config sections are auto-generated on first run under data/codelogic/Libraries/CL.MySQL2/.

config.mysql.json — per-database settings. Highlights:

Field Default Purpose
Host, Port, Database, Username, Password connection
EnablePooling, MinPoolSize, MaxPoolSize, ConnectionLifetime true/1/100/300s pool
SchemaSyncLevel Safe None / Safe / Additive / Full
SlowQueryThresholdMs 1000 slow query logging
QueryTimeoutMs 30000 default per-query timeout
MaxBatchInsertSize 500 InsertManyAsync chunk size
MaxInClauseValues 1000 IN-clause safety cap
PreparedStatementCacheSize 256 per-connection
N1DetectorThreshold 0 (off) warn on repeated query in request scope
CaptureExplainOnSlowQuery true attach EXPLAIN to SlowQueryEvent
DefaultStringSize 255 VARCHAR size when no [Column(Size)]
CacheEnabledOverride null (inherit) per-DB cache on/off

config.mysql.cache.json — global cache settings. Highlights:

Field Default Purpose
Enabled true master switch
MaxEntries 10000 soft cap
DefaultTtlSeconds 60 when .WithCache() has no TTL
TimeQuantizeSeconds 60 round DateTime params in cache keys
PublishEvents true emit hit/miss events

Transactions

await using var tx = await mysql.BeginTransactionAsync();
var repo = mysql.GetRepository<AccountRecord>(tx);
// ... work ...
await tx.CommitAsync();  // auto-rolls back on dispose if not committed

Requirements

License

MIT — see LICENSE

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.

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
4.5.4-preview.59 66 5/24/2026
4.5.3-preview.58 42 5/24/2026
4.5.2 97 5/24/2026
4.5.2-preview.57 56 5/24/2026
4.5.1 103 5/24/2026
4.5.1-preview.60 51 5/24/2026
4.5.1-preview.56 57 5/24/2026
4.4.2-preview.53 44 5/24/2026
4.4.1 94 5/24/2026
4.4.1-preview.55 48 5/24/2026
4.4.1-preview.52 44 5/24/2026
4.3.0-preview.51 50 5/24/2026
4.2.3 122 5/15/2026
4.2.2 118 5/15/2026
4.2.1 91 5/15/2026
4.2.0 115 5/15/2026
4.1.3 99 5/15/2026
4.1.2 99 5/15/2026
4.1.1 212 5/10/2026
4.1.0 112 5/9/2026
Loading failed

# CL.MySQL2 — Changelog

All notable changes to **CodeLogic.MySQL2** are documented here. Versions follow
[Semantic Versioning](https://semver.org/). The version listed here matches the
NuGet package version of `CodeLogic.MySQL2`.

## [4.2.3] — 2026-05-15

### Fixed

- **Cache orphan accumulation on mutations.** `QueryCache.Invalidate(tableName)`
 previously only bumped the per-table version counter — old cache entries
 (now unreachable via the read path because the cache key changed) lingered
 in the underlying store until TTL or LRU swept them. On a busy app this
 produced unbounded memory growth. Invalidate now also calls
 `ICacheStore.EvictByTableAsync` to sweep matching entries in the same step.
- **SmartCachePool orphan tracking.** Each pool entry now remembers the
 cache key it last wrote. If the next tick computes a different key
 (because a mutation bumped the table version between ticks), the
 previous key is evicted explicitly. Works on any `ICacheStore`
 implementation including ones that can't enumerate (Redis without
 SCAN, memcached).

### Added

- `ICacheStore.EvictByTableAsync(tableName)` — bulk eviction by table.
 Default in-process implementation is an O(n) scan over current entries.
 Distributed adapters can override (e.g. Redis tag-set or key prefix).
- `ICacheStore.CountByTable()` — entries grouped by tableName for diagnostics.
- `QueryCache.GetStats()` → `QueryCacheStats(TotalEntries, EntriesByTable,
 TableVersions)`. Surfaced on the library API via `MySQL2Library.GetCacheStats()`
 so admin tools can render "what's in the cache right now" without
 dumping values.

## [4.2.2] — 2026-05-15

### Fixed

- **SmartCache pool no longer corrupts `ToListAsync` results.** The pool's
 refresh factory stored the unwrapped `List<T>` instead of the
 `Result<List<T>>` that the cache-aside read path expects. After the first
 background refresh tick, every subsequent read failed the `(Result<List<T>>)`
 cast inside `GetOrSetAsync`, the outer try/catch turned it into a Failure
 Result, and callers saw an empty list (manifested as "No servers configured"
 / empty leaderboards roughly one refresh interval after warm-up). `FirstOrDefaultAsync`
 and `CountAsync` already cached the full Result and were unaffected; only
 `ToListAsync` was wrong.

## [4.2.1] — 2026-05-15

### Fixed

- **Failure Results no longer poison the cache.** Previously, a query that
 failed (e.g. transient connection error during a cold warm-up) had its
 `Result<T>.Failure` value cached just like a successful one — subsequent
 reads served the failure until the entry's TTL expired or a pool refresh
 overwrote it. Now `QueryCache.GetOrSetAsync` skips writing failure Results,
 evicts any pre-existing failure entry on read, and `SetDirectAsync` (the
 smart-cache pool's refresh path) refuses to write failures too. Empty
 server lists / leaderboards on first request after a deploy are gone.

## [4.2.0] — 2026-05-15

### Added

- **Smart-cache pool warm-up on registration.** `RegisterCachePool` now
 accepts an optional `warmUp: Func<Task>` callback that fires as a
 fire-and-forget task right after the pool starts. The callback just
 calls the queries that should be warm — they auto-register with the
 pool via their normal `.SmartCache(name)` decoration — so the cache
 is hot before the first user request hits it. Exceptions are caught
 and logged; the pool stays lazy if warm-up fails.
- `SmartCachePool.WarmUp(Func<Task>)` — public method exposing the same
 behaviour for callers that want to warm a pool independently of
 registration.

## [4.1.2] — 2026-05-15

### Added

- **Smart cache pools** — named groups of cached queries kept warm by a
 background timer (`mysql.RegisterCachePool("dashboard", refreshEvery: 30s)`,
 opt in per-query with `.SmartCache("dashboard")`). Reads after the first
 populate the cache never block on the DB — the pool's timer re-runs every
 registered query in the background and overwrites the entry.
- `SmartCachePool.RefreshNowAsync()` — out-of-schedule refresh, useful right
 after a deploy to prime the cache before the first user hits the page.
- `MySQL2Library.GetCachePoolStats()` — diagnostic snapshot per pool
 (entry count, ticks fired, ticks failed, last tick UTC).
- `QueryCache.SetDirectAsync(...)` — internal cache write API used by pools.

### Notes

- Smart cache is mutually exclusive with `.WithCache(TimeSpan)` — if both are
 set, the pool wins and the TTL comes from `refreshEvery * 2`.
- Unknown pool name on `.SmartCache(name)` logs a warning and falls back to
 non-cached execution (no exception).
- Per-pool eviction policy: an entry that has not been read for
 `MaxIdleFires` (default 3) consecutive ticks is dropped from the refresh
 list. Bounds cardinality on parameterized queries.
- Smart cache is disabled inside a transaction scope (same as `.WithCache`).
- Single-node only in v4.2. Multi-node coordination is on the roadmap.

## [4.1.1] — 2026-04-17

### Fixed

- Qualify LHS columns in upsert SET clauses so `UpsertAsync` no longer
 generates ambiguous column references when the table has columns whose
 names clash with parameter placeholders.

## [4.1.0] — 2026-04-17

### Added

- **Typed upsert** — `UpsertAsync` + `UpsertWithIncrementsAsync` on the
 repository. Compiles to `INSERT ... ON DUPLICATE KEY UPDATE ...` with
 full LINQ-shaped value/increment expressions on the update side.

### Changed

- `LibraryManifest.Version` now reads from the assembly's `AssemblyVersion`
 attribute at runtime instead of being a hard-coded string. Keeps the
 manifest honest across rebuilds.

## [4.0.4] — 2026-04-16

### Changed

- README + manifest refresh across every CodeLogic library for the v4 baseline.
- No functional changes vs 4.0.3.

## [4.0.1] — 2026-04-09

### Fixed

- Drop the `Expression.Compile().DynamicInvoke()` fast-path inside the SQL
 expression visitor — it broke on closures over generic types. The visitor
 now always walks the tree.

## [4.0.0] — 2026-04-09

Major rewrite. Breaking.

### Added

- **Projection pushdown** — `.Select<TResult>(x => new { ... })` emits a
 real `SELECT col1, col2, ...` column list instead of `SELECT *`. Combined
 with compiled materializers this often cuts row-transfer bandwidth by 80%+.
- **SQL-side aggregation** — `.GroupBy(...).Select(g => new { g.Key,
 g.Sum(...), g.Average(...), ... })` translates to real `GROUP BY` +
 aggregate functions. No client-side row materialization.
- **`SqlFn` helpers** — server-side function markers (`SqlFn.DayOfWeek`,
 `SqlFn.Hour`, `SqlFn.BucketUtc`, `SqlFn.Coalesce`, `SqlFn.Round`, etc.)
 recognized by the translator — mirrors EF's `EF.Functions` pattern.
- **`[Index]` attribute** — declare named, unique, and covering indexes
 (with `Include = new[] { ... }`) at the column level.
- **`[RetainDays]` attribute** — opt entities into a daily background purge
 worker that runs batched `DELETE` until drained.
- **Working result cache** — `.WithCache(TimeSpan)` with two correctness
 fixes from prior versions:
 - DateTime closures near `UtcNow` are time-quantized to a configurable
   window (default 60s) so `.Where(x => x.At >= UtcNow.AddDays(-30))`
   stops producing a unique cache key per call.
 - Mutations bump a per-table version that participates in the cache
   key — invalidation is free (old keys become un-hittable, no eviction
   loop).
- **`EntityMetadata<T>` + compiled `Materializer<T>`** — reflection runs
 once per entity at first use; subsequent reads use a compiled
 reader-to-entity function.
- **Observability events** — `QueryExecutedEvent`, `SlowQueryEvent`,
 `CacheHitEvent`, `CacheMissEvent`, `N1QueryDetectedEvent`,
 `TableSyncedEvent` publish to the CodeLogic event bus.
- **`MaxBatchInsertSize`, `MaxInClauseValues`, `PreparedStatementCacheSize`,
 `N1DetectorThreshold`, `CaptureExplainOnSlowQuery`, `DefaultStringSize`,
 `CacheEnabledOverride`** — per-database config knobs.
- **`CacheConfiguration`** — global cache settings (`Enabled`,
 `MaxEntries`, `DefaultTtlSeconds`, `TimeQuantizeSeconds`,
 `PublishEvents`).

### Changed

- Republished as v4.0.0 to reset the version line with the new package shape.
- All public APIs refreshed under the v4 baseline.

## Earlier releases

Pre-4.0 history is retained in the
[git log](https://github.com/Media2A/CodeLogic.Libs/commits/main/CL.MySQL2)
but is not documented in detail here — the library shape changed
significantly in the v4 rewrite.