From daff1446d80c4f6e443116ace6c7fd793815b5e4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 21:45:13 -0400 Subject: [PATCH] feat(inbound): expose read-only Database helper on InboundScriptContext --- .../InboundDatabaseHelper.cs | 6 +- .../InboundScriptExecutor.cs | 40 +++++++++ .../InboundScriptExecutorTests.cs | 84 +++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs index 56852fa1..2cb6b53b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundDatabaseHelper.cs @@ -12,15 +12,16 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// 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; } /// First column of the first row converted to T (default if no rows). public T? QuerySingle(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> 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; diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs index 3f26fe5d..2a63d32b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundScriptExecutor.cs @@ -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(); + 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 /// public RouteHelper Route { get; } + /// + /// Read-only database access for the script (named connections; bound parameters). + /// + public InboundDatabaseHelper Database { get; } + /// /// The cancellation token for script execution. /// @@ -340,14 +377,17 @@ public class InboundScriptContext /// /// The input parameters for the script. /// The route helper for cross-site routing. + /// The read-only database helper for the script. /// The cancellation token. public InboundScriptContext( IReadOnlyDictionary parameters, RouteHelper route, + InboundDatabaseHelper database, CancellationToken cancellationToken = default) { Parameters = new ScriptParameters(parameters); Route = route; + Database = database; CancellationToken = cancellationToken; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs index 5d37c772..ccbec43f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/InboundScriptExecutorTests.cs @@ -1,9 +1,13 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; 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; namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; @@ -539,6 +543,86 @@ public class InboundScriptExecutorTests Assert.Null(captured!.ParentExecutionId); } + // --- IpsenMES MoveIn: the read-only Database helper is exposed on the script + // context and resolves IDatabaseGateway from a DI scope per execution. --- + + [Fact] + public async Task Script_UsingDatabase_QueriesViaGateway() + { + // A script that calls Database.QuerySingle runs against an executor whose + // ServiceProvider registers an IDatabaseGateway backed by in-memory SQLite. + var services = new ServiceCollection(); + services.AddSingleton(SeededSqliteGateway()); + using var provider = services.BuildServiceProvider(); + + var executor = new InboundScriptExecutor( + NullLogger.Instance, provider); + + var method = new ApiMethod( + "movein", + "return new { v = Database.QuerySingle(\"BTDB\", \"SELECT Code FROM Machine WHERE SAPID=@s\", new { s = (string)Parameters[\"sap\"] }) };") + { + Id = 1, + TimeoutSeconds = 10, + // null ReturnDefinition → ReturnValueValidator is unconstrained. + ReturnDefinition = null, + }; + + var result = await executor.ExecuteAsync( + method, + new Dictionary { { "sap", "131453" } }, + _route, + TimeSpan.FromSeconds(10)); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Contains("Z28061A", result.ResultJson!); + } + + [Fact] + public async Task Script_NotUsingDatabase_RunsWithProviderLackingGateway() + { + // A script that never touches Database must run even when no IDatabaseGateway + // is registered — the helper is built via GetService (nullable) and only + // throws on first use, which this script never reaches. + var executor = new InboundScriptExecutor( + NullLogger.Instance, Substitute.For()); + + var method = new ApiMethod("no-db", "return 99;") { Id = 1, TimeoutSeconds = 10 }; + + var result = await executor.ExecuteAsync( + method, + new Dictionary(), + _route, + TimeSpan.FromSeconds(10)); + + Assert.True(result.Success, result.ErrorMessage); + Assert.Contains("99", result.ResultJson!); + } + + // SQLite-backed IDatabaseGateway fake (mirrors InboundDatabaseHelperTests). The + // shared in-memory db is seeded with Machine(Code,SAPID)=('Z28061A','131453'). + 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 SeededSqliteGateway() + { + var keep = new SqliteConnection("DataSource=file:movein-exec?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-exec?mode=memory&cache=shared"); + } + private sealed class CompileLogCounter { public int CompilationFailures;