using System.Data.Common;
using System.Globalization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// Scoped, parameterized database access exposed to inbound API scripts as
/// InboundScriptContext.Database. This is the dedicated, curated data-access
/// helper authorized by the design doc (Component-InboundAPI.md, "Database access") —
/// not a raw connection handed to the script. All ADO.NET stays internal here: scripts
/// call / /
/// by name and never reference System.Data.
///
///
/// SQL-injection protection (InboundAPI-026). Statement text is authored by the
/// (design-time) method script, but every value is bound as a named SQL
/// parameter (anonymous-object properties become @-prefixed parameters via
/// ) and is NEVER string-concatenated into the command text.
/// Request-derived values therefore reach the database only through parameter binding,
/// closing the injection vector. Connection access is restricted to the named
/// connections configured on the central — a script
/// cannot supply an arbitrary connection string.
///
///
///
/// Reads and writes are both permitted (InboundAPI-026 design decision): the move-in
/// integration needs to record results, not just read them. Use
/// / for reads and for writes.
///
///
///
/// Async + deadline-bound (InboundAPI-027). Every call uses the async ADO.NET path
/// end-to-end (no .GetAwaiter().GetResult() blocking a pool thread) and honours the
/// executing method's deadline token on the command itself, with a
/// backstop derived from the method timeout — so a slow query is bounded by the method
/// timeout instead of running unbounded.
///
///
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly CancellationToken _ct;
private readonly int _commandTimeoutSeconds;
///
/// Initializes the helper.
///
/// The central database gateway, or null when no gateway is registered (the helper then throws on first use).
/// The executing method's deadline token; forwarded to every async DB call so a slow query is bounded by the method timeout.
/// The method timeout; used to derive a backstop. (the default) leaves the provider default.
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct, TimeSpan commandTimeout = default)
{
_gateway = gateway;
_ct = ct;
_commandTimeoutSeconds = commandTimeout > TimeSpan.Zero
? (int)Math.Ceiling(commandTimeout.TotalSeconds)
: 0;
}
/// First column of the first row converted to (default if no rows).
/// The type to convert the scalar result to.
/// Name of a connection configured on the central database gateway.
/// The SQL statement. Values MUST be supplied via , not concatenated.
/// Optional anonymous object whose properties become bound @-prefixed parameters.
/// The converted scalar, or default when there are no rows / a NULL value.
public async Task QuerySingleAsync(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
var result = await cmd.ExecuteScalarAsync(_ct);
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).
/// Name of a connection configured on the central database gateway.
/// The SQL statement. Values MUST be supplied via , not concatenated.
/// Optional anonymous object whose properties become bound @-prefixed parameters.
/// The result rows; empty when nothing matches.
public async Task>> QueryAsync(
string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
await using var reader = await cmd.ExecuteReaderAsync(_ct);
var rows = new List>();
while (await reader.ReadAsync(_ct))
{
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;
}
///
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
/// values are still bound as parameters, never concatenated.
///
/// Name of a connection configured on the central database gateway.
/// The SQL statement. Values MUST be supplied via , not concatenated.
/// Optional anonymous object whose properties become bound @-prefixed parameters.
/// The number of rows affected.
public async Task ExecuteAsync(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
await using var conn = await _gateway.GetConnectionAsync(connectionName, _ct);
await using var cmd = CreateCommand(conn, sql, parameters);
return await cmd.ExecuteNonQueryAsync(_ct);
}
private DbCommand CreateCommand(DbConnection conn, string sql, object? parameters)
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// InboundAPI-027: a CommandTimeout backstop derived from the method timeout so a
// slow query cannot outrun the method deadline even if the provider does not
// honour token cancellation mid-statement.
if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds;
AddParameters(cmd, parameters);
return cmd;
}
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);
}
}
}