feat(inbound): expose read-only Database helper on InboundScriptContext

This commit is contained in:
Joseph Doherty
2026-06-16 21:45:13 -04:00
parent 16fc62bfa0
commit daff1446d8
3 changed files with 128 additions and 2 deletions
@@ -12,15 +12,16 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// </summary>
public sealed class InboundDatabaseHelper
{
private readonly IDatabaseGateway _gateway;
private readonly IDatabaseGateway? _gateway;
private readonly CancellationToken _ct;
public InboundDatabaseHelper(IDatabaseGateway gateway, CancellationToken ct)
public InboundDatabaseHelper(IDatabaseGateway? gateway, CancellationToken ct)
{ _gateway = gateway; _ct = ct; }
/// <summary>First column of the first row converted to T (default if no rows).</summary>
public T? QuerySingle<T>(string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
@@ -35,6 +36,7 @@ public sealed class InboundDatabaseHelper
public IReadOnlyList<IReadOnlyDictionary<string, object?>> Query(
string connectionName, string sql, object? parameters = null)
{
if (_gateway is null) throw new InvalidOperationException("Database is not available for this inbound method");
using var conn = _gateway.GetConnectionAsync(connectionName, _ct).GetAwaiter().GetResult();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
@@ -2,8 +2,10 @@ using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -234,8 +236,31 @@ public class InboundScriptExecutor
using var cts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, timeoutCts.Token);
// IpsenMES MoveIn: expose a read-only Database helper to the script.
// Resolve the gateway from a fresh DI scope (declared outside the try so it
// lives until after the script handler runs and is disposed here) so a scoped
// IDatabaseGateway is honoured. GetService (not GetRequiredService) so a method
// that never touches Database still runs even when no IDatabaseGateway is
// registered; the helper throws on first use if built without a gateway.
//
// A service provider that cannot produce a scope (e.g. a bare test double with
// no IServiceScopeFactory) is tolerated: the gateway is simply unavailable, so
// a script not using Database still runs, while one that does fails on use.
IServiceScope? scope = null;
try
{
scope = _serviceProvider.CreateScope();
}
catch (InvalidOperationException)
{
// No scope factory available (provider does not support scoping).
}
try
{
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token);
// InboundAPI-016: bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
@@ -246,6 +271,7 @@ public class InboundScriptExecutor
var context = new InboundScriptContext(
parameters,
route.WithDeadline(cts.Token).WithParentExecutionId(parentExecutionId),
dbHelper,
cts.Token);
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
@@ -312,6 +338,12 @@ public class InboundScriptExecutor
// WP-5: Safe error message, no internal details
return new InboundScriptResult(false, null, "Internal script error");
}
finally
{
// Dispose the per-execution DI scope (if one was created) only after the
// script handler has finished running and its result been serialized.
scope?.Dispose();
}
}
}
@@ -330,6 +362,11 @@ public class InboundScriptContext
/// </summary>
public RouteHelper Route { get; }
/// <summary>
/// Read-only database access for the script (named connections; bound parameters).
/// </summary>
public InboundDatabaseHelper Database { get; }
/// <summary>
/// The cancellation token for script execution.
/// </summary>
@@ -340,14 +377,17 @@ public class InboundScriptContext
/// </summary>
/// <param name="parameters">The input parameters for the script.</param>
/// <param name="route">The route helper for cross-site routing.</param>
/// <param name="database">The read-only database helper for the script.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public InboundScriptContext(
IReadOnlyDictionary<string, object?> parameters,
RouteHelper route,
InboundDatabaseHelper database,
CancellationToken cancellationToken = default)
{
Parameters = new ScriptParameters(parameters);
Route = route;
Database = database;
CancellationToken = cancellationToken;
}
}