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); } } }