fix(localdb): Task 4 review fixes — synchronous allow-list, dictionary params, async conn setup, dispose guard
This commit is contained in:
@@ -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<string, object?></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<string, object?></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<string, object?></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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user