fix(localdb): Task 4 review fixes — synchronous allow-list, dictionary params, async conn setup, dispose guard

This commit is contained in:
Joseph Doherty
2026-07-17 21:11:07 -04:00
parent 827bf44684
commit 12b8839f33
3 changed files with 148 additions and 20 deletions
@@ -6,13 +6,24 @@ namespace ZB.MOM.WW.LocalDb;
/// Consumer SQL API over the embedded SQLite local database. Every connection this
/// hands out has the <c>zb_hlc_next()</c> UDF registered, so capture triggers on
/// registered tables can stamp writes fail-closed.
/// SQL references parameters with the <c>@name</c> marker; supported parameter shapes are
/// an anonymous object / POCO (public instance properties bind as <c>@PropertyName</c>) or an
/// <c>IDictionary&lt;string, object?&gt;</c> (each entry binds as <c>@Key</c>). Null values bind as SQL NULL.
/// </summary>
public interface ILocalDb
{
/// <summary>Executes a non-query statement; returns the affected row count.</summary>
/// <summary>
/// Executes a non-query statement; returns the affected row count.
/// <paramref name="parameters"/> may be an anonymous object / POCO (properties → <c>@name</c>)
/// or an <c>IDictionary&lt;string, object?&gt;</c> (entries → <c>@key</c>).
/// </summary>
Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default);
/// <summary>Executes a query and projects each row through <paramref name="map"/>.</summary>
/// <summary>
/// Executes a query and projects each row through <paramref name="map"/>.
/// <paramref name="parameters"/> may be an anonymous object / POCO (properties → <c>@name</c>)
/// or an <c>IDictionary&lt;string, object?&gt;</c> (entries → <c>@key</c>).
/// </summary>
Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default);
/// <summary>Begins a transaction pinned to a single UDF-registered connection.</summary>
@@ -12,27 +12,40 @@ namespace ZB.MOM.WW.LocalDb.Internal;
/// </summary>
internal sealed class SqliteLocalDb : ILocalDb, IDisposable
{
// Allow-list because the value is interpolated into a PRAGMA statement (pragmas cannot be parameterized).
private static readonly string[] AllowedSynchronous = ["OFF", "NORMAL", "FULL", "EXTRA"];
private static readonly ConcurrentDictionary<Type, PropertyInfo[]> PropertyCache = new();
private readonly LocalDbOptions _options;
private readonly string _synchronous;
private readonly int _busyTimeoutMs;
private readonly string _connectionString;
// Held open for the object lifetime: it anchors the WAL journal and is the connection
// Dispose flushes the HLC high-water mark through, so the flush cannot fail on reopen.
private readonly SqliteConnection _master;
private readonly HybridLogicalClock _clock;
private readonly string _nodeId;
private bool _disposed;
internal HybridLogicalClock Clock => _clock;
internal string NodeId => _nodeId;
public SqliteLocalDb(LocalDbOptions options)
{
_options = options;
if (string.IsNullOrWhiteSpace(options.Path))
throw new ArgumentException("LocalDbOptions.Path is required.", nameof(options));
_synchronous = Array.Find(AllowedSynchronous,
s => s.Equals(options.Synchronous, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException(
$"LocalDbOptions.Synchronous must be one of {string.Join("/", AllowedSynchronous)}; got '{options.Synchronous}'.",
nameof(options));
_busyTimeoutMs = options.BusyTimeoutMs;
_connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();
_master = new SqliteConnection(_connectionString);
_master.Open();
ExecuteRaw(_master,
$"PRAGMA journal_mode=WAL; PRAGMA synchronous={_options.Synchronous}; " +
$"PRAGMA busy_timeout={_options.BusyTimeoutMs}; PRAGMA foreign_keys=ON;");
ExecuteRaw(_master, "PRAGMA journal_mode=WAL; " + PerConnectionPragmas());
LocalDbSchema.EnsureCreated(_master);
@@ -46,21 +59,43 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
(_nodeId, _) = LocalDbSchema.ReadMeta(_master);
}
// journal_mode is a durable DB property already set on the file; synchronous is per-connection.
private string PerConnectionPragmas() =>
$"PRAGMA synchronous={_synchronous}; PRAGMA busy_timeout={_busyTimeoutMs}; PRAGMA foreign_keys=ON;";
public SqliteConnection CreateConnection()
{
var conn = new SqliteConnection(_connectionString);
conn.Open();
// journal_mode is a durable DB property already set on the file; synchronous is per-connection.
ExecuteRaw(conn,
$"PRAGMA synchronous={_options.Synchronous}; " +
$"PRAGMA busy_timeout={_options.BusyTimeoutMs}; PRAGMA foreign_keys=ON;");
ExecuteRaw(conn, PerConnectionPragmas());
conn.CreateFunction("zb_hlc_next", () => _clock.Next());
return conn;
}
internal async Task<SqliteConnection> CreateConnectionAsync(CancellationToken ct)
{
var conn = new SqliteConnection(_connectionString);
try
{
await conn.OpenAsync(ct);
await using (var cmd = conn.CreateCommand())
{
cmd.CommandText = PerConnectionPragmas();
await cmd.ExecuteNonQueryAsync(ct);
}
conn.CreateFunction("zb_hlc_next", () => _clock.Next());
return conn;
}
catch
{
await conn.DisposeAsync();
throw;
}
}
public async Task<int> ExecuteAsync(string sql, object? parameters = null, CancellationToken ct = default)
{
await using var conn = CreateConnection();
await using var conn = await CreateConnectionAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
Bind(cmd, parameters);
@@ -69,7 +104,7 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
public async Task<IReadOnlyList<T>> QueryAsync<T>(string sql, Func<SqliteDataReader, T> map, object? parameters = null, CancellationToken ct = default)
{
await using var conn = CreateConnection();
await using var conn = await CreateConnectionAsync(ct);
await using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
Bind(cmd, parameters);
@@ -78,7 +113,7 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
public async Task<ILocalDbTransaction> BeginTransactionAsync(CancellationToken ct = default)
{
var conn = CreateConnection();
var conn = await CreateConnectionAsync(ct);
try
{
var tx = (SqliteTransaction)await conn.BeginTransactionAsync(ct);
@@ -93,6 +128,9 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
LocalDbSchema.FlushHlcHighWater(_master, _clock.Current);
_master.Dispose();
}
@@ -108,13 +146,18 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
private static void Bind(SqliteCommand cmd, object? parameters)
{
if (parameters is null)
return;
foreach (var prop in PropertiesOf(parameters.GetType()))
switch (parameters)
{
var value = prop.GetValue(parameters);
cmd.Parameters.AddWithValue("@" + prop.Name, value ?? DBNull.Value);
case null:
return;
case IDictionary<string, object?> dict:
foreach (var (key, value) in dict)
cmd.Parameters.AddWithValue("@" + key, value ?? DBNull.Value);
return;
default:
foreach (var prop in PropertiesOf(parameters.GetType()))
cmd.Parameters.AddWithValue("@" + prop.Name, prop.GetValue(parameters) ?? DBNull.Value);
return;
}
}