diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs new file mode 100644 index 00000000..56852fa1 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs @@ -0,0 +1,68 @@ +using System.Data.Common; +using System.Globalization; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; + +namespace ZB.MOM.WW.ScadaBridge.InboundAPI; + +/// +/// Read-only database access exposed to inbound API scripts. All ADO.NET stays +/// internal here — scripts call QuerySingle/Query by name and never reference +/// System.Data. Named connections only; parameters are bound (anonymous-object +/// properties become @-prefixed SQL parameters), never string-concatenated. +/// +public sealed class InboundDatabaseHelper +{ + private readonly IDatabaseGateway _gateway; + private readonly CancellationToken _ct; + + public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct) + { _gateway = gateway; _ct = ct; } + + /// First column of the first row converted to T (default if no rows). + public T? QuerySingle(string connectionName, string sql, object? parameters = null) + { + using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + AddParameters(cmd, parameters); + var result = cmd.ExecuteScalar(); + if (result is null or DBNull) return default; + if (result is T t) return t; + return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); + } + + /// All rows as column→value dictionaries (case-insensitive keys). + public IReadOnlyList> Query( + string connectionName, string sql, object? parameters = null) + { + using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + AddParameters(cmd, parameters); + using var reader = cmd.ExecuteReader(); + var rows = new List>(); + while (reader.Read()) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < reader.FieldCount; i++) + { + var v = reader.GetValue(i); + row[reader.GetName(i)] = v is DBNull ? null : v; + } + rows.Add(row); + } + return rows; + } + + private static void AddParameters(DbCommand cmd, object? parameters) + { + if (parameters is null) return; + foreach (var prop in parameters.GetType().GetProperties()) + { + var p = cmd.CreateParameter(); + p.ParameterName = "@" + prop.Name; + p.Value = prop.GetValue(parameters) ?? DBNull.Value; + cmd.Parameters.Add(p); + } + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs new file mode 100644 index 00000000..e50becd6 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundDatabaseHelperTests.cs @@ -0,0 +1,47 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.InboundAPI; +using Xunit; + +public class InboundDatabaseHelperTests +{ + private sealed class SqliteGateway : IDatabaseGateway + { + private readonly string _cs; + public SqliteGateway(string cs) => _cs = cs; + public async Task GetConnectionAsync(string name, CancellationToken ct = default) + { var c = new SqliteConnection(_cs); await c.OpenAsync(ct); return c; } + public Task CachedWriteAsync(string c, string s, + IReadOnlyDictionary? p = null, string? o = null, CancellationToken ct = default, + TrackedOperationId? t = null, Guid? e = null, string? src = null, Guid? pe = null) + => throw new NotImplementedException(); + } + + private static SqliteGateway SeededGateway() + { + var keep = new SqliteConnection("DataSource=file:movein?mode=memory&cache=shared"); + keep.Open(); // keep-alive: shared in-memory db lives until process exit + using var cmd = keep.CreateCommand(); + cmd.CommandText = "CREATE TABLE IF NOT EXISTS Machine(Code TEXT, SAPID TEXT); DELETE FROM Machine; INSERT INTO Machine VALUES('Z28061A','131453');"; + cmd.ExecuteNonQuery(); + return new SqliteGateway("DataSource=file:movein?mode=memory&cache=shared"); + } + + [Fact] + public void QuerySingle_returns_first_column_with_bound_parameter() + { + var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None); + var code = helper.QuerySingle("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "131453" }); + Assert.Equal("Z28061A", code); + } + + [Fact] + public void QuerySingle_returns_default_when_no_rows() + { + var helper = new InboundDatabaseHelper(SeededGateway(), CancellationToken.None); + var code = helper.QuerySingle("BTDB", "SELECT Code FROM Machine WHERE SAPID=@s", new { s = "999999" }); + Assert.Null(code); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj index 2af82258..6dc60653 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.csproj @@ -15,6 +15,7 @@ +